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.ciyam AT - 1.3.8 + 1.4.0 1.3.4 1.3.5 1.3.6 1.3.7 1.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.0 org.qortal qortal - 3.6.1 + 4.3.0 jar true @@ -11,7 +11,7 @@ 0.15.10 1.69 ${maven.build.timestamp} - 1.3.8 + 1.4.0 3.6 1.8 2.6 @@ -36,6 +36,7 @@ 4.10 1.45.1 3.19.4 + 1.17 src/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) settingValue); + return array.toString(4); + } + + return settingValue.toString(); + } catch (IllegalAccessException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); + } + } + @GET @Path("/stop") @Operation( @@ -182,6 +235,37 @@ public class AdminResource { return "true"; } + @GET + @Path("/restart") + @Operation( + summary = "Restart", + description = "Restart", + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @SecurityRequirement(name = "apiKey") + public String restart(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + Security.checkApiCallAllowed(request); + + new Thread(() -> { + // Short sleep to allow HTTP response body to be emitted + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + // Not important + } + + AutoUpdate.attemptRestart(); + + }).start(); + + return "true"; + } + @GET @Path("/summary") @Operation( @@ -222,6 +306,42 @@ public class AdminResource { } } + @GET + @Path("/summary/alltime") + @Operation( + summary = "Summary of activity since genesis", + responses = { + @ApiResponse( + content = @Content(schema = @Schema(implementation = ActivitySummary.class)) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public ActivitySummary allTimeSummary(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + Security.checkApiCallAllowed(request); + + ActivitySummary summary = new ActivitySummary(); + + try (final Repository repository = RepositoryManager.getRepository()) { + int startHeight = 1; + long start = repository.getBlockRepository().fromHeight(startHeight).getTimestamp(); + int endHeight = repository.getBlockRepository().getBlockchainHeight(); + + summary.setBlockCount(endHeight - startHeight); + + summary.setTransactionCountByType(repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight)); + + summary.setAssetsIssued(repository.getAssetRepository().getRecentAssetIds(start).size()); + + summary.setNamesRegistered (repository.getNameRepository().getRecentNames(start).size()); + + return summary; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/enginestats") @Operation( @@ -698,6 +818,64 @@ public class AdminResource { } } + @POST + @Path("/repository/archive/rebuild") + @Operation( + summary = "Rebuild archive", + description = "Rebuilds archive files, using the specified serialization version", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "number", example = "2" + ) + ) + ), + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String rebuildArchive(@HeaderParam(Security.API_KEY_HEADER) String apiKey, Integer serializationVersion) { + Security.checkApiCallAllowed(request); + + // Default serialization version to value specified in settings + if (serializationVersion == null) { + serializationVersion = Settings.getInstance().getDefaultArchiveVersion(); + } + + try { + // We don't actually need to lock the blockchain here, but we'll do it anyway so that + // the node can focus on rebuilding rather than synchronizing / minting. + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + BlockArchiveRebuilder blockArchiveRebuilder = new BlockArchiveRebuilder(serializationVersion); + blockArchiveRebuilder.start(); + + return "true"; + + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); + + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform rebuild + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @DELETE @Path("/repository") @Operation( diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/restricted/resource/BootstrapResource.java similarity index 96% rename from src/main/java/org/qortal/api/resource/BootstrapResource.java rename to src/main/java/org/qortal/api/restricted/resource/BootstrapResource.java index b9382dcb..47b7cf42 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/BootstrapResource.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.media.Content; @@ -60,7 +60,7 @@ public class BootstrapResource { bootstrap.validateBlockchain(); return bootstrap.create(); - } catch (DataException | InterruptedException | IOException e) { + } catch (Exception e) { LOGGER.info("Unable to create bootstrap", e); throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); } diff --git a/src/main/java/org/qortal/api/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java similarity index 62% rename from src/main/java/org/qortal/api/resource/RenderResource.java rename to src/main/java/org/qortal/api/restricted/resource/RenderResource.java index 519e722d..92f72032 100644 --- a/src/main/java/org/qortal/api/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -1,4 +1,4 @@ -package org.qortal.api.resource; +package org.qortal.api.restricted.resource; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; @@ -8,7 +8,6 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import java.io.*; import java.nio.file.Paths; -import java.util.Map; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -28,8 +27,8 @@ import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.controller.arbitrary.ArbitraryDataRenderManager; import org.qortal.data.transaction.ArbitraryTransactionData.*; import org.qortal.repository.DataException; -import org.qortal.settings.Settings; import org.qortal.arbitrary.ArbitraryDataFile.*; +import org.qortal.settings.Settings; import org.qortal.utils.Base58; @@ -43,60 +42,6 @@ public class RenderResource { @Context HttpServletResponse response; @Context ServletContext context; - @POST - @Path("/preview") - @Operation( - summary = "Generate preview URL based on a user-supplied path and service", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string", example = "/Users/user/Documents/MyStaticWebsite" - ) - ) - ), - responses = { - @ApiResponse( - description = "a temporary URL to preview the website", - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - @SecurityRequirement(name = "apiKey") - public String preview(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String directoryPath) { - Security.checkApiCallAllowed(request); - Method method = Method.PUT; - Compression compression = Compression.ZIP; - - ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(Paths.get(directoryPath), - null, Service.WEBSITE, 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.createException(request, ApiError.REPOSITORY_ISSUE); - } catch (RuntimeException e) { - LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - } - - ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile(); - if (arbitraryDataFile != null) { - String digest58 = arbitraryDataFile.digest58(); - if (digest58 != null) { - return "http://localhost:12393/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret()); - } - } - return "Unable to generate preview URL"; - } - @POST @Path("/authorize/{resourceId}") @SecurityRequirement(name = "apiKey") @@ -140,8 +85,10 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getIndexBySignature(@PathParam("signature") String signature, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); - return this.get(signature, ResourceIdType.SIGNATURE, null, "/", null, "/render/signature", true, true, theme); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); + + return this.get(signature, ResourceIdType.SIGNATURE, null, null, "/", null, "/render/signature", true, true, theme); } @GET @@ -149,8 +96,10 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getPathBySignature(@PathParam("signature") String signature, @PathParam("path") String inPath, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); - return this.get(signature, ResourceIdType.SIGNATURE, null, inPath,null, "/render/signature", true, true, theme); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); + + return this.get(signature, ResourceIdType.SIGNATURE, null, null, inPath,null, "/render/signature", true, true, theme); } @GET @@ -158,8 +107,10 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getIndexByHash(@PathParam("hash") String hash58, @QueryParam("secret") String secret58, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); - return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, "/", secret58, "/render/hash", true, false, theme); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); + + return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, "/", secret58, "/render/hash", true, false, theme); } @GET @@ -168,8 +119,10 @@ public class RenderResource { public HttpServletResponse getPathByHash(@PathParam("hash") String hash58, @PathParam("path") String inPath, @QueryParam("secret") String secret58, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); - return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, inPath, secret58, "/render/hash", true, false, theme); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); + + return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, inPath, secret58, "/render/hash", true, false, theme); } @GET @@ -178,10 +131,13 @@ public class RenderResource { public HttpServletResponse getPathByName(@PathParam("service") Service service, @PathParam("name") String name, @PathParam("path") String inPath, + @QueryParam("identifier") String identifier, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, name, service, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, name, service, null); + String prefix = String.format("/render/%s", service); - return this.get(name, ResourceIdType.NAME, service, inPath, null, prefix, true, true, theme); + return this.get(name, ResourceIdType.NAME, service, identifier, inPath, null, prefix, true, true, theme); } @GET @@ -189,19 +145,22 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getIndexByName(@PathParam("service") Service service, @PathParam("name") String name, + @QueryParam("identifier") String identifier, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, name, service, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, name, service, null); + String prefix = String.format("/render/%s", service); - return this.get(name, ResourceIdType.NAME, service, "/", null, prefix, true, true, theme); + return this.get(name, ResourceIdType.NAME, service, identifier, "/", null, prefix, true, true, theme); } - private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, - String secret58, String prefix, boolean usePrefix, boolean async, String theme) { + private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, + String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String theme) { - 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, "render", request, response, context); if (theme != null) { renderer.setTheme(theme); diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index 405fe7e5..9ac9f87d 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -2,7 +2,9 @@ package org.qortal.api.websocket; import java.io.IOException; import java.io.StringWriter; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import org.eclipse.jetty.websocket.api.Session; @@ -21,6 +23,8 @@ import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import static org.qortal.data.chat.ChatMessage.Encoding; + @WebSocket @SuppressWarnings("serial") public class ActiveChatsWebSocket extends ApiWebSocket { @@ -62,7 +66,9 @@ public class ActiveChatsWebSocket extends ApiWebSocket { @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { - /* ignored */ + if (Objects.equals(message, "ping")) { + session.getRemote().sendStringByFuture("pong"); + } } private void onNotify(Session session, ChatTransactionData chatTransactionData, String ourAddress, AtomicReference previousOutput) { @@ -75,7 +81,7 @@ public class ActiveChatsWebSocket extends ApiWebSocket { } try (final Repository repository = RepositoryManager.getRepository()) { - ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress); + ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress, getTargetEncoding(session)); StringWriter stringWriter = new StringWriter(); @@ -93,4 +99,12 @@ public class ActiveChatsWebSocket extends ApiWebSocket { } } + private Encoding getTargetEncoding(Session session) { + // Default to Base58 if not specified, for backwards support + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + List encodingList = queryParams.get("encoding"); + String encoding = (encodingList != null && encodingList.size() == 1) ? encodingList.get(0) : "BASE58"; + return Encoding.valueOf(encoding); + } + } diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index 9760b7f0..3046c1c1 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -2,10 +2,7 @@ package org.qortal.api.websocket; import java.io.IOException; import java.io.StringWriter; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.WebSocketException; @@ -22,6 +19,8 @@ import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import static org.qortal.data.chat.ChatMessage.Encoding; + @WebSocket @SuppressWarnings("serial") public class ChatMessagesWebSocket extends ApiWebSocket { @@ -35,6 +34,16 @@ public class ChatMessagesWebSocket extends ApiWebSocket { @Override public void onWebSocketConnect(Session session) { Map> queryParams = session.getUpgradeRequest().getParameterMap(); + Encoding encoding = getTargetEncoding(session); + + List limitList = queryParams.get("limit"); + Integer limit = (limitList != null && limitList.size() == 1) ? Integer.parseInt(limitList.get(0)) : null; + + List offsetList = queryParams.get("offset"); + Integer offset = (offsetList != null && offsetList.size() == 1) ? Integer.parseInt(offsetList.get(0)) : null; + + List reverseList = queryParams.get("offset"); + Boolean reverse = (reverseList != null && reverseList.size() == 1) ? Boolean.getBoolean(reverseList.get(0)) : null; List txGroupIds = queryParams.get("txGroupId"); if (txGroupIds != null && txGroupIds.size() == 1) { @@ -47,7 +56,11 @@ public class ChatMessagesWebSocket extends ApiWebSocket { txGroupId, null, null, - null, null, null); + null, + null, + null, + encoding, + limit, offset, reverse); sendMessages(session, chatMessages); } catch (DataException e) { @@ -74,8 +87,12 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, + null, involvingAddresses, - null, null, null); + null, + encoding, + limit, offset, reverse); sendMessages(session, chatMessages); } catch (DataException e) { @@ -101,7 +118,9 @@ public class ChatMessagesWebSocket extends ApiWebSocket { @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { - /* ignored */ + if (Objects.equals(message, "ping")) { + session.getRemote().sendStringByFuture("pong"); + } } private void onNotify(Session session, ChatTransactionData chatTransactionData, int txGroupId) { @@ -149,7 +168,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { // Convert ChatTransactionData to ChatMessage ChatMessage chatMessage; try (final Repository repository = RepositoryManager.getRepository()) { - chatMessage = repository.getChatRepository().toChatMessage(chatTransactionData); + chatMessage = repository.getChatRepository().toChatMessage(chatTransactionData, getTargetEncoding(session)); } catch (DataException e) { // No output this time? return; @@ -158,4 +177,12 @@ public class ChatMessagesWebSocket extends ApiWebSocket { sendMessages(session, Collections.singletonList(chatMessage)); } + private Encoding getTargetEncoding(Session session) { + // Default to Base58 if not specified, for backwards support + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + List encodingList = queryParams.get("encoding"); + String encoding = (encodingList != null && encodingList.size() == 1) ? encodingList.get(0) : "BASE58"; + return Encoding.valueOf(encoding); + } + } diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 78c53dc3..9c48b018 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -24,6 +24,7 @@ import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.qortal.api.model.CrossChainOfferSummary; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; +import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crosschain.ACCT; import org.qortal.crosschain.AcctMode; @@ -315,7 +316,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { throw new DataException("Couldn't fetch historic trades from repository"); for (ATStateData historicAtState : historicAtStates) { - CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null); + CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null, null); if (!isHistoric.test(historicOfferSummary)) continue; @@ -330,8 +331,10 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { } } - private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException { - CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, CrossChainTradeData crossChainTradeData, Long timestamp) throws DataException { + if (crossChainTradeData == null) { + crossChainTradeData = acct.populateTradeData(repository, atState); + } long atStateTimestamp; @@ -346,9 +349,16 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { private static List produceSummaries(Repository repository, ACCT acct, List atStates, Long timestamp) throws DataException { List offerSummaries = new ArrayList<>(); + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); - for (ATStateData atState : atStates) - offerSummaries.add(produceSummary(repository, acct, atState, timestamp)); + // Ignore trade if it has failed + if (TradeBot.getInstance().isFailedTrade(repository, crossChainTradeData)) { + continue; + } + + offerSummaries.add(produceSummary(repository, acct, atState, crossChainTradeData, timestamp)); + } return offerSummaries; } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java index 4f0e3835..fba6a32b 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java @@ -2,6 +2,7 @@ package org.qortal.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.metadata.ArbitraryDataMetadataCache; import org.qortal.arbitrary.misc.Service; @@ -53,10 +54,6 @@ public class ArbitraryDataBuilder { /** * Process transactions, but do not build anything * This is useful for checking the status of a given resource - * - * @throws DataException - * @throws IOException - * @throws MissingDataException */ public void process() throws DataException, IOException, MissingDataException { this.fetchTransactions(); @@ -68,10 +65,6 @@ public class ArbitraryDataBuilder { /** * Build the latest state of a given resource - * - * @throws DataException - * @throws IOException - * @throws MissingDataException */ public void build() throws DataException, IOException, MissingDataException { this.process(); @@ -88,7 +81,7 @@ public class ArbitraryDataBuilder { if (latestPut == null) { String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", this.name, this.service, this.identifierString()); - throw new DataException(message); + throw new DataNotPublishedException(message); } this.latestPutTransaction = latestPut; diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java index 1e86ee98..71378461 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java @@ -4,6 +4,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.repository.DataException; import org.qortal.settings.Settings; import org.qortal.utils.Base58; @@ -15,7 +16,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.*; -import java.util.stream.Stream; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; @@ -79,17 +79,31 @@ public class ArbitraryDataFile { this.signature = signature; } - public ArbitraryDataFile(byte[] fileContent, byte[] signature) throws DataException { + public ArbitraryDataFile(byte[] fileContent, byte[] signature, boolean useTemporaryFile) throws DataException { if (fileContent == null) { LOGGER.error("fileContent is null"); return; } + this.chunks = new ArrayList<>(); this.hash58 = Base58.encode(Crypto.digest(fileContent)); this.signature = signature; LOGGER.trace(String.format("File digest: %s, size: %d bytes", this.hash58, fileContent.length)); - Path outputFilePath = getOutputFilePath(this.hash58, signature, true); + Path outputFilePath; + if (useTemporaryFile) { + try { + outputFilePath = Files.createTempFile("qortalRawData", null); + outputFilePath.toFile().deleteOnExit(); + } + catch (IOException e) { + throw new DataException(String.format("Unable to write data with hash %s to temporary file: %s", this.hash58, e.getMessage())); + } + } + else { + outputFilePath = getOutputFilePath(this.hash58, signature, true); + } + File outputFile = outputFilePath.toFile(); try (FileOutputStream outputStream = new FileOutputStream(outputFile)) { outputStream.write(fileContent); @@ -111,6 +125,41 @@ public class ArbitraryDataFile { return ArbitraryDataFile.fromHash58(Base58.encode(hash), signature); } + public static ArbitraryDataFile fromRawData(byte[] data, byte[] signature) throws DataException { + if (data == null) { + return null; + } + return new ArbitraryDataFile(data, signature, true); + } + + public static ArbitraryDataFile fromTransactionData(ArbitraryTransactionData transactionData) throws DataException { + ArbitraryDataFile arbitraryDataFile = null; + byte[] signature = transactionData.getSignature(); + byte[] data = transactionData.getData(); + + if (data == null) { + return null; + } + + // Create data file + switch (transactionData.getDataType()) { + case DATA_HASH: + arbitraryDataFile = ArbitraryDataFile.fromHash(data, signature); + break; + + case RAW_DATA: + arbitraryDataFile = ArbitraryDataFile.fromRawData(data, signature); + break; + } + + // Set metadata hash + if (arbitraryDataFile != null) { + arbitraryDataFile.setMetadataHash(transactionData.getMetadataHash()); + } + + return arbitraryDataFile; + } + public static ArbitraryDataFile fromPath(Path path, byte[] signature) { if (path == null) { return null; @@ -260,6 +309,11 @@ public class ArbitraryDataFile { this.chunks = new ArrayList<>(); if (file != null) { + if (file.exists() && file.length() <= chunkSize) { + // No need to split into chunks if we're already below the chunk size + return 0; + } + try (FileInputStream fileInputStream = new FileInputStream(file); BufferedInputStream bis = new BufferedInputStream(fileInputStream)) { @@ -388,12 +442,15 @@ public class ArbitraryDataFile { return false; } - public boolean deleteAll() { + public boolean deleteAll(boolean deleteMetadata) { // Delete the complete file boolean fileDeleted = this.delete(); - // Delete the metadata file - boolean metadataDeleted = this.deleteMetadata(); + // Delete the metadata file if requested + boolean metadataDeleted = false; + if (deleteMetadata) { + metadataDeleted = this.deleteMetadata(); + } // Delete the individual chunks boolean chunksDeleted = this.deleteAllChunks(); @@ -612,6 +669,22 @@ public class ArbitraryDataFile { return this.chunks.size(); } + public int fileCount() { + int fileCount = this.chunkCount(); + + if (fileCount == 0) { + // Transactions without any chunks can already be treated as a complete file + fileCount++; + } + + if (this.getMetadataHash() != null) { + // Add the metadata file + fileCount++; + } + + return fileCount; + } + public List getChunks() { return this.chunks; } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java index 5f6695df..1fd388da 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java @@ -18,7 +18,7 @@ public class ArbitraryDataFileChunk extends ArbitraryDataFile { } public ArbitraryDataFileChunk(byte[] fileContent, byte[] signature) throws DataException { - super(fileContent, signature); + super(fileContent, signature, false); } public static ArbitraryDataFileChunk fromHash58(String hash58, byte[] signature) throws DataException { diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index 5d4b015c..b9e62e56 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -4,11 +4,11 @@ import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataBuildManager; import org.qortal.controller.arbitrary.ArbitraryDataManager; -import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; import org.qortal.crypto.AES; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.ArbitraryTransactionData.*; @@ -18,10 +18,7 @@ import org.qortal.repository.RepositoryManager; import org.qortal.arbitrary.ArbitraryDataFile.*; import org.qortal.settings.Settings; import org.qortal.transform.Transformer; -import org.qortal.utils.ArbitraryTransactionUtils; -import org.qortal.utils.Base58; -import org.qortal.utils.FilesystemUtils; -import org.qortal.utils.ZipUtils; +import org.qortal.utils.*; import javax.crypto.BadPaddingException; import javax.crypto.IllegalBlockSizeException; @@ -37,6 +34,9 @@ import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; public class ArbitraryDataReader { @@ -59,6 +59,13 @@ public class ArbitraryDataReader { private int layerCount; private byte[] latestSignature; + // The resource being read + ArbitraryDataResource arbitraryDataResource = null; + + // Track resources that are currently being loaded, to avoid duplicate concurrent builds + // TODO: all builds could be handled by the build queue (even synchronous ones), to avoid the need for this + private static Map inProgress = Collections.synchronizedMap(new HashMap<>()); + public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { // Ensure names are always lowercase if (resourceIdType == ResourceIdType.NAME) { @@ -115,6 +122,11 @@ public class ArbitraryDataReader { return new ArbitraryDataBuildQueueItem(this.resourceId, this.resourceIdType, this.service, this.identifier); } + private ArbitraryDataResource createArbitraryDataResource() { + return new ArbitraryDataResource(this.resourceId, this.resourceIdType, this.service, this.identifier); + } + + /** * loadAsynchronously * @@ -148,9 +160,6 @@ public class ArbitraryDataReader { * If no exception is thrown, you can then use getFilePath() to access the data immediately after returning * * @param overwrite - set to true to force rebuild an existing cache - * @throws IOException - * @throws DataException - * @throws MissingDataException */ public void loadSynchronously(boolean overwrite) throws DataException, IOException, MissingDataException { try { @@ -162,6 +171,14 @@ public class ArbitraryDataReader { return; } + this.arbitraryDataResource = this.createArbitraryDataResource(); + + // Don't allow duplicate loads + if (!this.canStartLoading()) { + LOGGER.debug("Skipping duplicate load of {}", this.arbitraryDataResource); + return; + } + this.preExecute(); this.deleteExistingFiles(); this.fetch(); @@ -169,10 +186,18 @@ public class ArbitraryDataReader { this.uncompress(); this.validate(); + } catch (DataNotPublishedException e) { + if (e.getMessage() != null) { + // Log the message only, to avoid spamming the logs with a full stack trace + LOGGER.debug("DataNotPublishedException when trying to load QDN resource: {}", e.getMessage()); + } + this.deleteWorkingDirectory(); + throw e; + } catch (DataException e) { LOGGER.info("DataException when trying to load QDN resource", e); this.deleteWorkingDirectory(); - throw new DataException(e.getMessage()); + throw e; } finally { this.postExecute(); @@ -181,6 +206,7 @@ public class ArbitraryDataReader { private void preExecute() throws DataException { ArbitraryDataBuildManager.getInstance().setBuildInProgress(true); + this.checkEnabled(); this.createWorkingDirectory(); this.createUncompressedDirectory(); @@ -188,6 +214,9 @@ public class ArbitraryDataReader { private void postExecute() { ArbitraryDataBuildManager.getInstance().setBuildInProgress(false); + + this.arbitraryDataResource = this.createArbitraryDataResource(); + ArbitraryDataReader.inProgress.remove(this.arbitraryDataResource.getUniqueKey()); } private void checkEnabled() throws DataException { @@ -196,6 +225,17 @@ public class ArbitraryDataReader { } } + private boolean canStartLoading() { + // Avoid duplicate builds if we're already loading this resource + String uniqueKey = this.arbitraryDataResource.getUniqueKey(); + if (ArbitraryDataReader.inProgress.containsKey(uniqueKey)) { + return false; + } + ArbitraryDataReader.inProgress.put(uniqueKey, NTP.getTime()); + + return true; + } + private void createWorkingDirectory() throws DataException { try { Files.createDirectories(this.workingPath); @@ -207,7 +247,6 @@ public class ArbitraryDataReader { /** * Working directory should only be deleted on failure, since it is currently used to * serve a cached version of the resource for subsequent requests. - * @throws IOException */ private void deleteWorkingDirectory() { try { @@ -287,7 +326,7 @@ public class ArbitraryDataReader { break; default: - throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType.toString())); + throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType)); } } @@ -343,11 +382,6 @@ public class ArbitraryDataReader { throw new DataException(String.format("Transaction data not found for signature %s", this.resourceId)); } - // Load hashes - byte[] digest = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - byte[] signature = transactionData.getSignature(); - // Load secret byte[] secret = transactionData.getSecret(); if (secret != null) { @@ -355,16 +389,17 @@ public class ArbitraryDataReader { } // Load data file(s) - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); ArbitraryTransactionUtils.checkAndRelocateMiscFiles(transactionData); - arbitraryDataFile.setMetadataHash(metadataHash); + if (arbitraryDataFile == null) { + throw new DataException(String.format("arbitraryDataFile is null")); + } if (!arbitraryDataFile.allFilesExist()) { - if (ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName())) { + if (ListUtils.isNameBlocked(transactionData.getName())) { throw new DataException( String.format("Unable to request missing data for file %s because the name is blocked", arbitraryDataFile)); - } - else { + } else { // Ask the arbitrary data manager to fetch data for this transaction String message; if (this.canRequestMissingFiles) { @@ -375,8 +410,7 @@ public class ArbitraryDataReader { } else { message = String.format("Unable to reissue request for missing file %s for signature %s due to rate limit. Please try again later.", arbitraryDataFile, Base58.encode(transactionData.getSignature())); } - } - else { + } else { message = String.format("Missing data for file %s", arbitraryDataFile); } @@ -386,21 +420,25 @@ public class ArbitraryDataReader { } } - if (arbitraryDataFile.allChunksExist() && !arbitraryDataFile.exists()) { - // We have all the chunks but not the complete file, so join them - arbitraryDataFile.join(); + // Data hashes need some extra processing + if (transactionData.getDataType() == DataType.DATA_HASH) { + if (arbitraryDataFile.allChunksExist() && !arbitraryDataFile.exists()) { + // We have all the chunks but not the complete file, so join them + arbitraryDataFile.join(); + } + + // If the complete file still doesn't exist then something went wrong + if (!arbitraryDataFile.exists()) { + throw new IOException(String.format("File doesn't exist: %s", arbitraryDataFile)); + } + // Ensure the complete hash matches the joined chunks + if (!Arrays.equals(arbitraryDataFile.digest(), transactionData.getData())) { + // Delete the invalid file + arbitraryDataFile.delete(); + throw new DataException("Unable to validate complete file hash"); + } } - // If the complete file still doesn't exist then something went wrong - if (!arbitraryDataFile.exists()) { - throw new IOException(String.format("File doesn't exist: %s", arbitraryDataFile)); - } - // Ensure the complete hash matches the joined chunks - if (!Arrays.equals(arbitraryDataFile.digest(), digest)) { - // Delete the invalid file - arbitraryDataFile.delete(); - throw new DataException("Unable to validate complete file hash"); - } // Ensure the file's size matches the size reported by the transaction (throws a DataException if not) arbitraryDataFile.validateFileSize(transactionData.getSize()); @@ -427,10 +465,11 @@ public class ArbitraryDataReader { byte[] secret = this.secret58 != null ? Base58.decode(this.secret58) : null; if (secret != null && secret.length == Transformer.AES256_LENGTH) { try { - LOGGER.info("Decrypting using algorithm {}...", algorithm); + LOGGER.debug("Decrypting {} using algorithm {}...", this.arbitraryDataResource, algorithm); Path unencryptedPath = Paths.get(this.workingPath.toString(), "zipped.zip"); SecretKey aesKey = new SecretKeySpec(secret, 0, secret.length, "AES"); AES.decryptFile(algorithm, aesKey, this.filePath.toString(), unencryptedPath.toString()); + LOGGER.debug("Finished decrypting {} using algorithm {}", this.arbitraryDataResource, algorithm); // Replace filePath pointer with the encrypted file path // Don't delete the original ArbitraryDataFile, as this is handled in the cleanup phase @@ -438,7 +477,7 @@ public class ArbitraryDataReader { } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException | IOException | InvalidKeyException e) { - LOGGER.info(String.format("Exception when decrypting using algorithm %s", algorithm), e); + LOGGER.info(String.format("Exception when decrypting %s using algorithm %s", this.arbitraryDataResource, algorithm), e); throw new DataException(String.format("Unable to decrypt file at path %s using algorithm %s: %s", this.filePath, algorithm, e.getMessage())); } } else { @@ -465,7 +504,9 @@ public class ArbitraryDataReader { // Handle each type of compression if (compression == Compression.ZIP) { + LOGGER.debug("Unzipping {}...", this.arbitraryDataResource); ZipUtils.unzip(this.filePath.toString(), this.uncompressedPath.getParent().toString()); + LOGGER.debug("Finished unzipping {}", this.arbitraryDataResource); } else if (compression == Compression.NONE) { Files.createDirectories(this.uncompressedPath); @@ -501,10 +542,12 @@ public class ArbitraryDataReader { private void validate() throws IOException, DataException { if (this.service.isValidationRequired()) { + LOGGER.debug("Validating {}...", this.arbitraryDataResource); Service.ValidationResult result = this.service.validate(this.filePath); if (result != Service.ValidationResult.OK) { throw new DataException(String.format("Validation of %s failed: %s", this.service, result.toString())); } + LOGGER.debug("Finished validating {}", this.arbitraryDataResource); } } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 847f2aa8..5c6cda63 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -2,6 +2,7 @@ package org.qortal.arbitrary; import com.google.common.io.Resources; 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.qortal.api.HTMLParser; @@ -34,36 +35,40 @@ public class ArbitraryDataRenderer { private final String resourceId; private final ResourceIdType resourceIdType; private final Service service; + private final String identifier; private String theme = "light"; private String inPath; private final String secret58; private final String prefix; - private final boolean usePrefix; + private final boolean includeResourceIdInPrefix; private final boolean async; + private final String qdnContext; private final HttpServletRequest request; private final HttpServletResponse response; private final ServletContext context; - public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, - String secret58, String prefix, boolean usePrefix, boolean async, + public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, + String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String qdnContext, HttpServletRequest request, HttpServletResponse response, ServletContext context) { this.resourceId = resourceId; this.resourceIdType = resourceIdType; this.service = service; + this.identifier = identifier != null ? identifier : "default"; this.inPath = inPath; this.secret58 = secret58; this.prefix = prefix; - this.usePrefix = usePrefix; + this.includeResourceIdInPrefix = includeResourceIdInPrefix; this.async = async; + this.qdnContext = qdnContext; this.request = request; this.response = response; this.context = context; } public HttpServletResponse render() { - if (!inPath.startsWith(File.separator)) { - inPath = File.separator + inPath; + if (!inPath.startsWith("/")) { + inPath = "/" + inPath; } // Don't render data if QDN is disabled @@ -71,14 +76,14 @@ public class ArbitraryDataRenderer { return ArbitraryDataRenderer.getResponse(response, 500, "QDN is disabled in settings"); } - ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, null); + ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, identifier); arbitraryDataReader.setSecret58(secret58); // Optional, used for loading encrypted file hashes only try { if (!arbitraryDataReader.isCachedDataAvailable()) { // If async is requested, show a loading screen whilst build is in progress if (async) { arbitraryDataReader.loadAsynchronously(false, 10); - return this.getLoadingResponse(service, resourceId, theme); + return this.getLoadingResponse(service, resourceId, identifier, theme); } // Otherwise, loop until we have data @@ -111,23 +116,64 @@ public class ArbitraryDataRenderer { } String unzippedPath = path.toString(); + // Set path automatically for single file resources (except for apps, which handle routing differently) + String[] files = ArrayUtils.removeElement(new File(unzippedPath).list(), ".qortal"); + if (files.length == 1 && this.service != Service.APP) { + // This is a single file resource + inPath = files[0]; + } + try { String filename = this.getFilename(unzippedPath, inPath); - String filePath = Paths.get(unzippedPath, filename).toString(); + Path filePath = Paths.get(unzippedPath, filename); + boolean usingCustomRouting = false; + if (Files.isDirectory(filePath) && (!inPath.endsWith("/"))) { + inPath = inPath + "/"; + filename = this.getFilename(unzippedPath, inPath); + filePath = Paths.get(unzippedPath, filename); + } + + // If the file doesn't exist, we may need to route the request elsewhere, or cleanup + if (!Files.exists(filePath)) { + if (inPath.equals("/")) { + // Delete the unzipped folder if no index file was found + try { + FileUtils.deleteDirectory(new File(unzippedPath)); + } catch (IOException e) { + LOGGER.debug("Unable to delete directory: {}", unzippedPath, e); + } + } + + // If this is an app, then forward all unhandled requests to the index, to give the app the option to route it + if (this.service == Service.APP) { + // Locate index file + List indexFiles = ArbitraryDataRenderer.indexFiles(); + for (String indexFile : indexFiles) { + Path indexPath = Paths.get(unzippedPath, indexFile); + if (Files.exists(indexPath)) { + // Forward request to index file + filePath = indexPath; + filename = indexFile; + usingCustomRouting = true; + break; + } + } + } + } if (HTMLParser.isHtmlFile(filename)) { // HTML file - needs to be parsed - byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory - HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data); + byte[] data = Files.readAllBytes(filePath); // TODO: limit file size that can be read into memory + HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, includeResourceIdInPrefix, data, qdnContext, service, identifier, theme, usingCustomRouting); htmlParser.addAdditionalHeaderTags(); - response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:; img-src 'self' data: blob:;"); + response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:;"); response.setContentType(context.getMimeType(filename)); response.setContentLength(htmlParser.getData().length); response.getOutputStream().write(htmlParser.getData()); } else { // Regular file - can be streamed directly - File file = new File(filePath); + File file = filePath.toFile(); FileInputStream inputStream = new FileInputStream(file); response.addHeader("Content-Security-Policy", "default-src 'self'"); response.setContentType(context.getMimeType(filename)); @@ -143,14 +189,6 @@ public class ArbitraryDataRenderer { return response; } catch (FileNotFoundException | NoSuchFileException e) { LOGGER.info("Unable to serve file: {}", e.getMessage()); - if (inPath.equals("/")) { - // Delete the unzipped folder if no index file was found - try { - FileUtils.deleteDirectory(new File(unzippedPath)); - } catch (IOException ioException) { - LOGGER.debug("Unable to delete directory: {}", unzippedPath, e); - } - } } catch (IOException e) { LOGGER.info("Unable to serve file at path {}: {}", inPath, e.getMessage()); } @@ -172,7 +210,7 @@ public class ArbitraryDataRenderer { return userPath; } - private HttpServletResponse getLoadingResponse(Service service, String name, String theme) { + private HttpServletResponse getLoadingResponse(Service service, String name, String identifier, String theme) { String responseString = ""; URL url = Resources.getResource("loading/index.html"); try { @@ -181,6 +219,7 @@ public class ArbitraryDataRenderer { // Replace vars responseString = responseString.replace("%%SERVICE%%", service.toString()); responseString = responseString.replace("%%NAME%%", name); + responseString = responseString.replace("%%IDENTIFIER%%", identifier); responseString = responseString.replace("%%THEME%%", theme); } catch (IOException e) { diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index 616c9b03..a4650dfc 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -3,6 +3,7 @@ package org.qortal.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataBuildManager; @@ -10,13 +11,13 @@ import org.qortal.controller.arbitrary.ArbitraryDataManager; import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; import org.qortal.data.arbitrary.ArbitraryResourceStatus; import org.qortal.data.transaction.ArbitraryTransactionData; -import org.qortal.list.ResourceListManager; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.ArbitraryTransactionUtils; import org.qortal.utils.FilesystemUtils; +import org.qortal.utils.ListUtils; import org.qortal.utils.NTP; import java.io.IOException; @@ -42,6 +43,7 @@ public class ArbitraryDataResource { private int layerCount; private Integer localChunkCount = null; private Integer totalChunkCount = null; + private boolean exists = false; public ArbitraryDataResource(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { this.resourceId = resourceId.toLowerCase(); @@ -60,6 +62,10 @@ public class ArbitraryDataResource { // Avoid this for "quick" statuses, to speed things up if (!quick) { this.calculateChunkCounts(); + + if (!this.exists) { + return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount); + } } if (resourceIdType != ResourceIdType.NAME) { @@ -68,8 +74,7 @@ public class ArbitraryDataResource { } // Check if the name is blocked - if (ResourceListManager.getInstance() - .listContains("blockedNames", this.resourceId, false)) { + if (ListUtils.isNameBlocked(this.resourceId)) { return new ArbitraryResourceStatus(Status.BLOCKED, this.localChunkCount, this.totalChunkCount); } @@ -134,21 +139,23 @@ public class ArbitraryDataResource { return null; } - public boolean delete() { + public boolean delete(boolean deleteMetadata) { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } List transactionDataList = new ArrayList<>(this.transactions); for (ArbitraryTransactionData transactionData : transactionDataList) { - byte[] hash = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - byte[] signature = transactionData.getSignature(); - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); + if (arbitraryDataFile == null) { + continue; + } // Delete any chunks or complete files from each transaction - arbitraryDataFile.deleteAll(); + arbitraryDataFile.deleteAll(deleteMetadata); } // Also delete cached data for the entire resource @@ -192,6 +199,9 @@ public class ArbitraryDataResource { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } List transactionDataList = new ArrayList<>(this.transactions); @@ -211,6 +221,14 @@ public class ArbitraryDataResource { private void calculateChunkCounts() { try { this.fetchTransactions(); + if (this.transactions == null) { + this.exists = false; + this.localChunkCount = 0; + this.totalChunkCount = 0; + return; + } + + this.exists = true; List transactionDataList = new ArrayList<>(this.transactions); int localChunkCount = 0; @@ -230,6 +248,9 @@ public class ArbitraryDataResource { private boolean isRateLimited() { try { this.fetchTransactions(); + if (this.transactions == null) { + return true; + } List transactionDataList = new ArrayList<>(this.transactions); @@ -253,6 +274,10 @@ public class ArbitraryDataResource { private boolean isDataPotentiallyAvailable() { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } + Long now = NTP.getTime(); if (now == null) { return false; @@ -284,6 +309,10 @@ public class ArbitraryDataResource { private boolean isDownloading() { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } + Long now = NTP.getTime(); if (now == null) { return false; @@ -325,7 +354,7 @@ public class ArbitraryDataResource { if (latestPut == null) { String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", this.resourceId, this.service, this.identifierString()); - throw new DataException(message); + throw new DataNotPublishedException(message); } this.latestPutTransaction = latestPut; @@ -336,7 +365,10 @@ public class ArbitraryDataResource { this.transactions = transactionDataList; this.layerCount = transactionDataList.size(); - } catch (DataException e) { + } catch (DataNotPublishedException e) { + // Ignore without logging + } + catch (DataException e) { LOGGER.info(String.format("Repository error when fetching transactions for resource %s: %s", this, e.getMessage())); } } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java index 0f3d4357..a9dd4fcf 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java @@ -9,6 +9,7 @@ import org.qortal.arbitrary.metadata.ArbitraryDataMetadataPatch; import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.crypto.AES; import org.qortal.crypto.Crypto; import org.qortal.data.PaymentData; import org.qortal.data.transaction.ArbitraryTransactionData; @@ -46,6 +47,7 @@ public class ArbitraryDataTransactionBuilder { private static final double MAX_FILE_DIFF = 0.5f; private final String publicKey58; + private final long fee; private final Path path; private final String name; private Method method; @@ -64,11 +66,12 @@ public class ArbitraryDataTransactionBuilder { private ArbitraryTransactionData arbitraryTransactionData; private ArbitraryDataFile arbitraryDataFile; - public ArbitraryDataTransactionBuilder(Repository repository, String publicKey58, Path path, String name, + public ArbitraryDataTransactionBuilder(Repository repository, String publicKey58, long fee, Path path, String name, Method method, Service service, String identifier, String title, String description, List tags, Category category) { this.repository = repository; this.publicKey58 = publicKey58; + this.fee = fee; this.path = path; this.name = name; this.method = method; @@ -179,6 +182,7 @@ public class ArbitraryDataTransactionBuilder { for (ModifiedPath path : metadata.getModifiedPaths()) { if (path.getDiffType() != DiffType.COMPLETE_FILE) { atLeastOnePatch = true; + break; } } } @@ -187,6 +191,14 @@ public class ArbitraryDataTransactionBuilder { return Method.PUT; } + // We can't use PATCH for on-chain data because this requires the .qortal directory, which can't be put on chain + final boolean isSingleFileResource = FilesystemUtils.isSingleFileResource(this.path, false); + final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(FilesystemUtils.getSingleFileContents(path).length) <= ArbitraryTransaction.MAX_DATA_SIZE); + if (shouldUseOnChainData) { + LOGGER.info("Data size is small enough to go on chain - using PUT"); + return Method.PUT; + } + // State is appropriate for a PATCH transaction return Method.PATCH; } @@ -227,10 +239,12 @@ public class ArbitraryDataTransactionBuilder { random.nextBytes(lastReference); } - Compression compression = Compression.ZIP; + // Single file resources are handled differently, especially for very small data payloads, as these go on chain + final boolean isSingleFileResource = FilesystemUtils.isSingleFileResource(path, false); + final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(FilesystemUtils.getSingleFileContents(path).length) <= ArbitraryTransaction.MAX_DATA_SIZE); - // FUTURE? Use zip compression for directories, or no compression for single files - // Compression compression = (path.toFile().isDirectory()) ? Compression.ZIP : Compression.NONE; + // Use zip compression if data isn't going on chain + Compression compression = shouldUseOnChainData ? Compression.NONE : Compression.ZIP; ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(path, name, service, identifier, method, compression, title, description, tags, category); @@ -248,45 +262,52 @@ public class ArbitraryDataTransactionBuilder { throw new DataException("Arbitrary data file is null"); } - // Get chunks metadata file + // Get metadata file ArbitraryDataFile metadataFile = arbitraryDataFile.getMetadataFile(); if (metadataFile == null && arbitraryDataFile.chunkCount() > 1) { throw new DataException(String.format("Chunks metadata data file is null but there are %d chunks", arbitraryDataFile.chunkCount())); } - String digest58 = arbitraryDataFile.digest58(); - if (digest58 == null) { - LOGGER.error("Unable to calculate file digest"); - throw new DataException("Unable to calculate file digest"); + // Default to using a data hash, with data held off-chain + ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH; + byte[] data = arbitraryDataFile.digest(); + + // For small, single-chunk resources, we can store the data directly on chain + if (shouldUseOnChainData && arbitraryDataFile.getBytes().length <= ArbitraryTransaction.MAX_DATA_SIZE && arbitraryDataFile.chunkCount() == 0) { + // Within allowed on-chain data size + dataType = DataType.RAW_DATA; + data = arbitraryDataFile.getBytes(); } final BaseTransactionData baseTransactionData = new BaseTransactionData(now, Group.NO_GROUP, - lastReference, creatorPublicKey, 0L, null); + lastReference, creatorPublicKey, fee, null); final int size = (int) arbitraryDataFile.size(); final int version = 5; final int nonce = 0; byte[] secret = arbitraryDataFile.getSecret(); - final ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH; - final byte[] digest = arbitraryDataFile.digest(); + final byte[] metadataHash = (metadataFile != null) ? metadataFile.getHash() : null; final List payments = new ArrayList<>(); ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, service, nonce, size, name, identifier, method, - secret, compression, digest, dataType, metadataHash, payments); + version, service.value, nonce, size, name, identifier, method, + secret, compression, data, dataType, metadataHash, payments); this.arbitraryTransactionData = transactionData; - } catch (DataException e) { + } catch (DataException | IOException e) { if (arbitraryDataFile != null) { - arbitraryDataFile.deleteAll(); + arbitraryDataFile.deleteAll(true); } - throw(e); + throw new DataException(e); } } private boolean isMetadataEqual(ArbitraryDataTransactionMetadata existingMetadata) { + if (existingMetadata == null) { + return !this.hasMetadata(); + } if (!Objects.equals(existingMetadata.getTitle(), this.title)) { return false; } @@ -302,6 +323,10 @@ public class ArbitraryDataTransactionBuilder { return true; } + private boolean hasMetadata() { + return (this.title != null || this.description != null || this.category != null || this.tags != null); + } + public void computeNonce() throws DataException { if (this.arbitraryTransactionData == null) { throw new DataException("Arbitrary transaction data is required to compute nonce"); @@ -313,7 +338,7 @@ public class ArbitraryDataTransactionBuilder { Transaction.ValidationResult result = transaction.isValidUnconfirmed(); if (result != Transaction.ValidationResult.OK) { - arbitraryDataFile.deleteAll(); + arbitraryDataFile.deleteAll(true); throw new DataException(String.format("Arbitrary transaction invalid: %s", result)); } LOGGER.info("Transaction is valid"); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java index 33802d4f..db29ee20 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java @@ -1,5 +1,7 @@ package org.qortal.arbitrary; +import com.j256.simplemagic.ContentInfo; +import com.j256.simplemagic.ContentInfoUtil; import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -23,16 +25,15 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; +import java.net.FileNameMap; +import java.net.URLConnection; +import java.nio.file.*; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class ArbitraryDataWriter { @@ -50,6 +51,8 @@ public class ArbitraryDataWriter { private final String description; private final List tags; private final Category category; + private List files; + private String mimeType; private int chunkSize = ArbitraryDataFile.CHUNK_SIZE; @@ -80,12 +83,15 @@ public class ArbitraryDataWriter { this.description = ArbitraryDataTransactionMetadata.limitDescription(description); this.tags = ArbitraryDataTransactionMetadata.limitTags(tags); this.category = category; + this.files = new ArrayList<>(); // Populated in buildFileList() + this.mimeType = null; // Populated in buildFileList() } public void save() throws IOException, DataException, InterruptedException, MissingDataException { try { this.preExecute(); this.validateService(); + this.buildFileList(); this.process(); this.compress(); this.encrypt(); @@ -101,10 +107,9 @@ public class ArbitraryDataWriter { private void preExecute() throws DataException { this.checkEnabled(); - // Enforce compression when uploading a directory - File file = new File(this.filePath.toString()); - if (file.isDirectory() && compression == Compression.NONE) { - throw new DataException("Unable to upload a directory without compression"); + // Enforce compression when uploading multiple files + if (!FilesystemUtils.isSingleFileResource(this.filePath, false) && compression == Compression.NONE) { + throw new DataException("Unable to publish multiple files without compression"); } // Create temporary working directory @@ -143,6 +148,48 @@ public class ArbitraryDataWriter { } } + private void buildFileList() throws IOException { + // Check if the path already points to a single file + boolean isSingleFile = this.filePath.toFile().isFile(); + Path singleFilePath = null; + if (isSingleFile) { + this.files.add(this.filePath.getFileName().toString()); + singleFilePath = this.filePath; + } + else { + // Multi file resources (or a single file in a directory) require a walk through the directory tree + try (Stream stream = Files.walk(this.filePath)) { + this.files = stream + .filter(Files::isRegularFile) + .map(p -> this.filePath.relativize(p).toString()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + if (this.files.size() == 1) { + singleFilePath = Paths.get(this.filePath.toString(), this.files.get(0)); + + // Update filePath to point to the single file (instead of the directory containing the file) + this.filePath = singleFilePath; + } + } + } + + if (singleFilePath != null) { + // Single file resource, so try and determine the MIME type + ContentInfoUtil util = new ContentInfoUtil(); + ContentInfo info = util.findMatch(singleFilePath.toFile()); + if (info != null) { + // Attempt to extract MIME type from file contents + this.mimeType = info.getMimeType(); + } + else { + // Fall back to using the filename + FileNameMap fileNameMap = URLConnection.getFileNameMap(); + this.mimeType = fileNameMap.getContentTypeFor(singleFilePath.toFile().getName()); + } + } + } + private void process() throws DataException, IOException, MissingDataException { switch (this.method) { @@ -269,9 +316,6 @@ public class ArbitraryDataWriter { if (chunkCount > 0) { LOGGER.info(String.format("Successfully split into %d chunk%s", chunkCount, (chunkCount == 1 ? "" : "s"))); } - else { - throw new DataException("Unable to split file into chunks"); - } } private void createMetadataFile() throws IOException, DataException { @@ -285,6 +329,8 @@ public class ArbitraryDataWriter { metadata.setTags(this.tags); metadata.setCategory(this.category); metadata.setChunks(this.arbitraryDataFile.chunkHashList()); + metadata.setFiles(this.files); + metadata.setMimeType(this.mimeType); metadata.write(); // Create an ArbitraryDataFile from the JSON file (we don't have a signature yet) diff --git a/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java b/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java new file mode 100644 index 00000000..4782826b --- /dev/null +++ b/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java @@ -0,0 +1,22 @@ +package org.qortal.arbitrary.exception; + +import org.qortal.repository.DataException; + +public class DataNotPublishedException extends DataException { + + public DataNotPublishedException() { + } + + public DataNotPublishedException(String message) { + super(message); + } + + public DataNotPublishedException(String message, Throwable cause) { + super(message, cause); + } + + public DataNotPublishedException(Throwable cause) { + super(cause); + } + +} diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java index 127fefb5..498f3296 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java @@ -2,12 +2,14 @@ package org.qortal.arbitrary.metadata; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.json.JSONException; import org.qortal.repository.DataException; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -34,7 +36,7 @@ public class ArbitraryDataMetadata { this.filePath = filePath; } - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { // To be overridden } @@ -44,8 +46,13 @@ public class ArbitraryDataMetadata { public void read() throws IOException, DataException { - this.loadJson(); - this.readJson(); + try { + this.loadJson(); + this.readJson(); + + } catch (JSONException e) { + throw new DataException(String.format("Unable to read JSON at path %s: %s", this.filePath, e.getMessage())); + } } public void write() throws IOException, DataException { @@ -58,6 +65,10 @@ public class ArbitraryDataMetadata { writer.close(); } + public void delete() throws IOException { + Files.delete(this.filePath); + } + protected void loadJson() throws IOException { File metadataFile = new File(this.filePath.toString()); @@ -65,7 +76,7 @@ public class ArbitraryDataMetadata { throw new IOException(String.format("Metadata file doesn't exist: %s", this.filePath.toString())); } - this.jsonString = new String(Files.readAllBytes(this.filePath)); + this.jsonString = new String(Files.readAllBytes(this.filePath), StandardCharsets.UTF_8); } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java index bd6bb219..e9b49298 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java @@ -1,5 +1,6 @@ package org.qortal.arbitrary.metadata; +import org.json.JSONException; import org.json.JSONObject; import org.qortal.repository.DataException; import org.qortal.utils.Base58; @@ -22,7 +23,7 @@ public class ArbitraryDataMetadataCache extends ArbitraryDataQortalMetadata { } @Override - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { if (this.jsonString == null) { throw new DataException("Patch JSON string is null"); } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java index 954dcb03..46a1f57e 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java @@ -3,6 +3,7 @@ package org.qortal.arbitrary.metadata; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import org.qortal.arbitrary.ArbitraryDataDiff.*; import org.qortal.repository.DataException; @@ -40,7 +41,7 @@ public class ArbitraryDataMetadataPatch extends ArbitraryDataQortalMetadata { } @Override - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { if (this.jsonString == null) { throw new DataException("Patch JSON string is null"); } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java index 4c188843..eb3d6cc9 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java @@ -2,12 +2,14 @@ package org.qortal.arbitrary.metadata; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.json.JSONException; import org.qortal.repository.DataException; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -46,20 +48,6 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { return null; } - protected void readJson() throws DataException { - // To be overridden - } - - protected void buildJson() { - // To be overridden - } - - - @Override - public void read() throws IOException, DataException { - this.loadJson(); - this.readJson(); - } @Override public void write() throws IOException, DataException { @@ -82,7 +70,7 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { throw new IOException(String.format("Patch file doesn't exist: %s", path.toString())); } - this.jsonString = new String(Files.readAllBytes(path)); + this.jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); } @@ -94,9 +82,4 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { } } - - public String getJsonString() { - return this.jsonString; - } - } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java index 0f8b676b..d9dba037 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java @@ -1,11 +1,13 @@ package org.qortal.arbitrary.metadata; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import org.qortal.arbitrary.misc.Category; import org.qortal.repository.DataException; import org.qortal.utils.Base58; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -19,9 +21,11 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { private String description; private List tags; private Category category; + private List files; + private String mimeType; private static int MAX_TITLE_LENGTH = 80; - private static int MAX_DESCRIPTION_LENGTH = 500; + private static int MAX_DESCRIPTION_LENGTH = 240; private static int MAX_TAG_LENGTH = 20; private static int MAX_TAGS_COUNT = 5; @@ -31,7 +35,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { } @Override - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { if (this.jsonString == null) { throw new DataException("Transaction metadata JSON string is null"); } @@ -77,6 +81,24 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { } this.chunks = chunksList; } + + List filesList = new ArrayList<>(); + if (metadata.has("files")) { + JSONArray files = metadata.getJSONArray("files"); + if (files != null) { + for (int i=0; i files) { + this.files = files; + } + + public List getFiles() { + return this.files; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getMimeType() { + return this.mimeType; + } + public boolean containsChunk(byte[] chunk) { for (byte[] c : this.chunks) { if (Arrays.equals(c, chunk)) { @@ -168,6 +218,25 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { // Static helper methods + public static String trimUTF8String(String string, int maxLength) { + byte[] inputBytes = string.getBytes(StandardCharsets.UTF_8); + int length = Math.min(inputBytes.length, maxLength); + byte[] outputBytes = new byte[length]; + + System.arraycopy(inputBytes, 0, outputBytes, 0, length); + String result = new String(outputBytes, StandardCharsets.UTF_8); + + // check if last character is truncated + int lastIndex = result.length() - 1; + + if (lastIndex > 0 && result.charAt(lastIndex) != string.charAt(lastIndex)) { + // last character is truncated so remove the last character + return result.substring(0, lastIndex); + } + + return result; + } + public static String limitTitle(String title) { if (title == null) { return null; @@ -176,7 +245,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { return null; } - return title.substring(0, Math.min(title.length(), MAX_TITLE_LENGTH)); + return trimUTF8String(title, MAX_TITLE_LENGTH); } public static String limitDescription(String description) { @@ -187,7 +256,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { return null; } - return description.substring(0, Math.min(description.length(), MAX_DESCRIPTION_LENGTH)); + return trimUTF8String(description, MAX_DESCRIPTION_LENGTH); } public static List limitTags(List tags) { diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 5d94d806..2b8f8d02 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -1,26 +1,66 @@ package org.qortal.arbitrary.misc; +import org.apache.commons.io.FilenameUtils; import org.json.JSONObject; import org.qortal.arbitrary.ArbitraryDataRenderer; import org.qortal.transaction.Transaction; import org.qortal.utils.FilesystemUtils; +import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Arrays; -import java.util.List; -import java.util.Map; +import java.util.*; + +import com.fasterxml.jackson.databind.ObjectMapper; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; public enum Service { - AUTO_UPDATE(1, false, null, null), - ARBITRARY_DATA(100, false, null, null), - WEBSITE(200, true, null, null) { + AUTO_UPDATE(1, false, null, false, false, null), + ARBITRARY_DATA(100, false, null, false, false, null), + QCHAT_ATTACHMENT(120, true, 1024*1024L, true, false, null) { @Override - public ValidationResult validate(Path path) { + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + + File[] files = path.toFile().listFiles(); + // If already a single file, replace the list with one that contains that file only + if (files == null && path.toFile().isFile()) { + files = new File[] { path.toFile() }; + } + // Now validate the file's extension + if (files != null && files[0] != null) { + final String extension = FilenameUtils.getExtension(files[0].getName()).toLowerCase(); + // We must allow blank file extensions because these are used by data published from a plaintext or base64-encoded string + final List allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", ""); + if (extension == null || !allowedExtensions.contains(extension)) { + return ValidationResult.INVALID_FILE_EXTENSION; + } + } + return ValidationResult.OK; + } + }, + QCHAT_ATTACHMENT_PRIVATE(121, true, 1024*1024L, true, true, null), + ATTACHMENT(130, false, 50*1024*1024L, true, false, null), + ATTACHMENT_PRIVATE(131, true, 50*1024*1024L, true, true, null), + FILE(140, false, null, true, false, null), + FILE_PRIVATE(141, true, null, true, true, null), + FILES(150, false, null, false, false, null), + CHAIN_DATA(160, true, 239L, true, false, null), + WEBSITE(200, true, null, false, false, null) { + @Override + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + // Custom validation function to require an index HTML file in the root directory List fileNames = ArbitraryDataRenderer.indexFiles(); String[] files = path.toFile().list(); @@ -35,33 +75,125 @@ public enum Service { return ValidationResult.MISSING_INDEX_FILE; } }, - GIT_REPOSITORY(300, false, null, null), - IMAGE(400, true, 10*1024*1024L, null), - THUMBNAIL(410, true, 500*1024L, null), - VIDEO(500, false, null, null), - AUDIO(600, false, null, null), - BLOG(700, false, null, null), - BLOG_POST(777, false, null, null), - BLOG_COMMENT(778, false, null, null), - DOCUMENT(800, false, null, null), - LIST(900, true, null, null), - PLAYLIST(910, true, null, null), - APP(1000, false, null, null), - METADATA(1100, false, null, null), - QORTAL_METADATA(1111, true, 10*1024L, Arrays.asList("title", "description", "tags")); + GIT_REPOSITORY(300, false, null, false, false, null), + IMAGE(400, true, 10*1024*1024L, true, false, null), + IMAGE_PRIVATE(401, true, 10*1024*1024L, true, true, null), + THUMBNAIL(410, true, 500*1024L, true, false, null), + QCHAT_IMAGE(420, true, 500*1024L, true, false, null), + VIDEO(500, false, null, true, false, null), + VIDEO_PRIVATE(501, true, null, true, true, null), + AUDIO(600, false, null, true, false, null), + AUDIO_PRIVATE(601, true, null, true, true, null), + QCHAT_AUDIO(610, true, 10*1024*1024L, true, false, null), + QCHAT_VOICE(620, true, 10*1024*1024L, true, false, null), + VOICE(630, true, 10*1024*1024L, true, false, null), + VOICE_PRIVATE(631, true, 10*1024*1024L, true, true, null), + PODCAST(640, false, null, true, false, null), + BLOG(700, false, null, false, false, null), + BLOG_POST(777, false, null, true, false, null), + BLOG_COMMENT(778, true, 500*1024L, true, false, null), + DOCUMENT(800, false, null, true, false, null), + DOCUMENT_PRIVATE(801, true, null, true, true, null), + LIST(900, true, null, true, false, null), + PLAYLIST(910, true, null, true, false, null), + APP(1000, true, 50*1024*1024L, false, false, null), + METADATA(1100, false, null, true, false, null), + JSON(1110, true, 25*1024L, true, false, null) { + @Override + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + + // Require valid JSON + byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024); + String json = new String(data, StandardCharsets.UTF_8); + try { + objectMapper.readTree(json); + return ValidationResult.OK; + } catch (IOException e) { + return ValidationResult.INVALID_CONTENT; + } + } + }, + GIF_REPOSITORY(1200, true, 25*1024*1024L, false, false, null) { + @Override + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + + // Custom validation function to require .gif files only, and at least 1 + int gifCount = 0; + File[] files = path.toFile().listFiles(); + // If already a single file, replace the list with one that contains that file only + if (files == null && path.toFile().isFile()) { + files = new File[] { path.toFile() }; + } + if (files != null) { + for (File file : files) { + if (file.getName().equals(".qortal")) { + continue; + } + if (file.isDirectory()) { + return ValidationResult.DIRECTORIES_NOT_ALLOWED; + } + String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); + if (!Objects.equals(extension, "gif")) { + return ValidationResult.INVALID_FILE_EXTENSION; + } + gifCount++; + } + } + if (gifCount == 0) { + return ValidationResult.MISSING_DATA; + } + return ValidationResult.OK; + } + }, + STORE(1300, false, null, true, false, null), + PRODUCT(1310, false, null, true, false, null), + OFFER(1330, false, null, true, false, null), + COUPON(1340, false, null, true, false, null), + CODE(1400, false, null, true, false, null), + PLUGIN(1410, false, null, true, false, null), + EXTENSION(1420, false, null, true, false, null), + GAME(1500, false, null, false, false, null), + ITEM(1510, false, null, true, false, null), + NFT(1600, false, null, true, false, null), + DATABASE(1700, false, null, false, false, null), + SNAPSHOT(1710, false, null, false, false, null), + COMMENT(1800, true, 500*1024L, true, false, null), + CHAIN_COMMENT(1810, true, 239L, true, false, null), + MAIL(1900, true, 1024*1024L, true, false, null), + MAIL_PRIVATE(1901, true, 1024*1024L, true, true, null), + MESSAGE(1910, true, 1024*1024L, true, false, null), + MESSAGE_PRIVATE(1911, true, 1024*1024L, true, true, null); public final int value; private final boolean requiresValidation; private final Long maxSize; + private final boolean single; + private final boolean isPrivate; private final List requiredKeys; private static final Map map = stream(Service.values()) .collect(toMap(service -> service.value, service -> service)); - Service(int value, boolean requiresValidation, Long maxSize, List requiredKeys) { + // For JSON validation + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String encryptedDataPrefix = "qortalEncryptedData"; + private static final String encryptedGroupDataPrefix = "qortalGroupEncryptedData"; + + Service(int value, boolean requiresValidation, Long maxSize, boolean single, boolean isPrivate, List requiredKeys) { this.value = value; this.requiresValidation = requiresValidation; this.maxSize = maxSize; + this.single = single; + this.isPrivate = isPrivate; this.requiredKeys = requiredKeys; } @@ -70,7 +202,9 @@ public enum Service { return ValidationResult.OK; } - byte[] data = FilesystemUtils.getSingleFileContents(path); + // Load the first 25KB of data. This only needs to be long enough to check the prefix + // and also to allow for possible additional future validation of smaller files. + byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024); long size = FilesystemUtils.getDirectorySize(path); // Validate max size if needed @@ -80,6 +214,22 @@ public enum Service { } } + // Validate file count if needed + if (this.single && data == null) { + return ValidationResult.INVALID_FILE_COUNT; + } + + // Validate private data for single file resources + if (this.single) { + String dataString = new String(data, StandardCharsets.UTF_8); + if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix) && !dataString.startsWith(encryptedGroupDataPrefix)) { + return ValidationResult.DATA_NOT_ENCRYPTED; + } + if (!this.isPrivate && (dataString.startsWith(encryptedDataPrefix) || dataString.startsWith(encryptedGroupDataPrefix))) { + return ValidationResult.DATA_ENCRYPTED; + } + } + // Validate required keys if needed if (this.requiredKeys != null) { if (data == null) { @@ -98,7 +248,12 @@ public enum Service { } public boolean isValidationRequired() { - return this.requiresValidation; + // We must always validate single file resources, to ensure they are actually a single file + return this.requiresValidation || this.single; + } + + public boolean isPrivate() { + return this.isPrivate; } public static Service valueOf(int value) { @@ -106,15 +261,53 @@ public enum Service { } public static JSONObject toJsonObject(byte[] data) { - String dataString = new String(data); + String dataString = new String(data, StandardCharsets.UTF_8); return new JSONObject(dataString); } + public static List publicServices() { + List privateServices = new ArrayList<>(); + for (Service service : Service.values()) { + if (!service.isPrivate) { + privateServices.add(service); + } + } + return privateServices; + } + + /** + * Fetch a list of Service objects that require encrypted data. + * + * These can ultimately be used to help inform the cleanup manager + * on the best order to delete files when the node runs out of space. + * Public data should be given priority over private data (unless + * this node is part of a data market contract for that data - this + * isn't developed yet). + * + * @return a list of Service objects that require encrypted data. + */ + public static List privateServices() { + List privateServices = new ArrayList<>(); + for (Service service : Service.values()) { + if (service.isPrivate) { + privateServices.add(service); + } + } + return privateServices; + } + public enum ValidationResult { OK(1), MISSING_KEYS(2), EXCEEDS_SIZE_LIMIT(3), - MISSING_INDEX_FILE(4); + MISSING_INDEX_FILE(4), + DIRECTORIES_NOT_ALLOWED(5), + INVALID_FILE_EXTENSION(6), + MISSING_DATA(7), + INVALID_FILE_COUNT(8), + INVALID_CONTENT(9), + DATA_NOT_ENCRYPTED(10), + DATA_ENCRYPTED(10); public final int value; diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 07c7db6f..16c061da 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -84,6 +84,7 @@ public class Block { TRANSACTION_PROCESSING_FAILED(53), TRANSACTION_ALREADY_PROCESSED(54), TRANSACTION_NEEDS_APPROVAL(55), + TRANSACTION_NOT_CONFIRMABLE(56), AT_STATES_MISMATCH(61), ONLINE_ACCOUNTS_INVALID(70), ONLINE_ACCOUNT_UNKNOWN(71), @@ -130,13 +131,16 @@ public class Block { /** Locally-generated AT fees */ protected long ourAtFees; // Generated locally + /** Cached online accounts validation decision, to avoid revalidating when true */ + private boolean onlineAccountsAlreadyValid = false; + @FunctionalInterface private interface BlockRewardDistributor { long distribute(long amount, Map balanceChanges) throws DataException; } /** Lazy-instantiated expanded info on block's online accounts. */ - private static class ExpandedAccount { + public static class ExpandedAccount { private final RewardShareData rewardShareData; private final int sharePercent; private final boolean isRecipientAlsoMinter; @@ -169,6 +173,13 @@ public class Block { } } + public Account getMintingAccount() { + return this.mintingAccount; + } + public Account getRecipientAccount() { + return this.recipientAccount; + } + /** * Returns share bin for expanded account. *

@@ -363,15 +374,24 @@ public class Block { return null; } + int height = parentBlockData.getHeight() + 1; long timestamp = calcTimestamp(parentBlockData, minter.getPublicKey(), minterLevel); long onlineAccountsTimestamp = OnlineAccountsManager.getCurrentOnlineAccountTimestamp(); - // Fetch our list of online accounts + // Fetch our list of online accounts, removing any that are missing a nonce List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); + onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); - // If mempow is active, remove any legacy accounts that are missing a nonce - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); + // After feature trigger, remove any online accounts that are level 0 + if (height >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + onlineAccounts.removeIf(a -> { + try { + return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; + } catch (DataException e) { + // Something went wrong, so remove the account + return true; + } + }); } if (onlineAccounts.isEmpty()) { @@ -412,29 +432,27 @@ public class Block { // Aggregated, single signature byte[] onlineAccountsSignatures = Qortal25519Extras.aggregateSignatures(signaturesToAggregate); - // Add nonces to the end of the online accounts signatures if mempow is active - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - try { - // Create ordered list of nonce values - List nonces = new ArrayList<>(); - for (int i = 0; i < onlineAccountsCount; ++i) { - Integer accountIndex = accountIndexes.get(i); - OnlineAccountData onlineAccountData = indexedOnlineAccounts.get(accountIndex); - nonces.add(onlineAccountData.getNonce()); - } - - // Encode the nonces to a byte array - byte[] encodedNonces = BlockTransformer.encodeOnlineAccountNonces(nonces); - - // Append the encoded nonces to the encoded online account signatures - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - outputStream.write(onlineAccountsSignatures); - outputStream.write(encodedNonces); - onlineAccountsSignatures = outputStream.toByteArray(); - } - catch (TransformationException | IOException e) { - return null; + // Add nonces to the end of the online accounts signatures + try { + // Create ordered list of nonce values + List nonces = new ArrayList<>(); + for (int i = 0; i < onlineAccountsCount; ++i) { + Integer accountIndex = accountIndexes.get(i); + OnlineAccountData onlineAccountData = indexedOnlineAccounts.get(accountIndex); + nonces.add(onlineAccountData.getNonce()); } + + // Encode the nonces to a byte array + byte[] encodedNonces = BlockTransformer.encodeOnlineAccountNonces(nonces); + + // Append the encoded nonces to the encoded online account signatures + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputStream.write(onlineAccountsSignatures); + outputStream.write(encodedNonces); + onlineAccountsSignatures = outputStream.toByteArray(); + } + catch (TransformationException | IOException e) { + return null; } byte[] minterSignature = minter.sign(BlockTransformer.getBytesForMinterSignature(parentBlockData, @@ -442,7 +460,6 @@ public class Block { int transactionCount = 0; byte[] transactionsSignature = null; - int height = parentBlockData.getHeight() + 1; int atCount = 0; long atFees = 0; @@ -550,6 +567,13 @@ public class Block { } + /** + * Force online accounts to be revalidated, e.g. at final stage of block minting. + */ + public void clearOnlineAccountsValidationCache() { + this.onlineAccountsAlreadyValid = false; + } + // More information /** @@ -644,6 +668,10 @@ public class Block { return this.atStates; } + public byte[] getAtStatesHash() { + return this.atStatesHash; + } + /** * Return expanded info on block's online accounts. *

@@ -1026,6 +1054,10 @@ public class Block { if (this.blockData.getHeight() != null && this.blockData.getHeight() == 1) return ValidationResult.OK; + // Don't bother revalidating if accounts have already been validated in this block + if (this.onlineAccountsAlreadyValid) + return ValidationResult.OK; + // Expand block's online accounts indexes into actual accounts ConciseSet accountIndexes = BlockTransformer.decodeOnlineAccounts(this.blockData.getEncodedOnlineAccounts()); // We use count of online accounts to validate decoded account indexes @@ -1036,6 +1068,15 @@ public class Block { if (onlineRewardShares == null) return ValidationResult.ONLINE_ACCOUNT_UNKNOWN; + // After feature trigger, require all online account minters to be greater than level 0 + if (this.getBlockData().getHeight() >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + List expandedAccounts = this.getExpandedAccounts(); + for (ExpandedAccount account : expandedAccounts) { + if (account.getMintingAccount().getEffectiveMintingLevel() == 0) + return ValidationResult.ONLINE_ACCOUNTS_INVALID; + } + } + // If block is past a certain age then we simply assume the signatures were correct long signatureRequirementThreshold = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMinLifetime(); if (this.blockData.getTimestamp() < signatureRequirementThreshold) @@ -1047,14 +1088,9 @@ public class Block { final int signaturesLength = Transformer.SIGNATURE_LENGTH; final int noncesLength = onlineRewardShares.size() * Transformer.INT_LENGTH; - if (this.blockData.getTimestamp() >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - // We expect nonces to be appended to the online accounts signatures - if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength + noncesLength) - return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; - } else { - if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength) - return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; - } + // We expect nonces to be appended to the online accounts signatures + if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength + noncesLength) + return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; // Check signatures long onlineTimestamp = this.blockData.getOnlineAccountsTimestamp(); @@ -1063,32 +1099,33 @@ public class Block { byte[] encodedOnlineAccountSignatures = this.blockData.getOnlineAccountsSignatures(); // Split online account signatures into signature(s) + nonces, then validate the nonces - if (this.blockData.getTimestamp() >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - byte[] extractedSignatures = BlockTransformer.extract(encodedOnlineAccountSignatures, 0, signaturesLength); - byte[] extractedNonces = BlockTransformer.extract(encodedOnlineAccountSignatures, signaturesLength, onlineRewardShares.size() * Transformer.INT_LENGTH); - encodedOnlineAccountSignatures = extractedSignatures; + byte[] extractedSignatures = BlockTransformer.extract(encodedOnlineAccountSignatures, 0, signaturesLength); + byte[] extractedNonces = BlockTransformer.extract(encodedOnlineAccountSignatures, signaturesLength, onlineRewardShares.size() * Transformer.INT_LENGTH); + encodedOnlineAccountSignatures = extractedSignatures; - List nonces = BlockTransformer.decodeOnlineAccountNonces(extractedNonces); + List nonces = BlockTransformer.decodeOnlineAccountNonces(extractedNonces); - // Build block's view of online accounts (without signatures, as we don't need them here) - Set onlineAccounts = new HashSet<>(); - for (int i = 0; i < onlineRewardShares.size(); ++i) { - Integer nonce = nonces.get(i); - byte[] publicKey = onlineRewardShares.get(i).getRewardSharePublicKey(); + // Build block's view of online accounts (without signatures, as we don't need them here) + Set onlineAccounts = new HashSet<>(); + for (int i = 0; i < onlineRewardShares.size(); ++i) { + Integer nonce = nonces.get(i); + byte[] publicKey = onlineRewardShares.get(i).getRewardSharePublicKey(); - OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, null, publicKey, nonce); - onlineAccounts.add(onlineAccountData); - } - - // Remove those already validated & cached by online accounts manager - no need to re-validate them - OnlineAccountsManager.getInstance().removeKnown(onlineAccounts, onlineTimestamp); - - // Validate the rest - for (OnlineAccountData onlineAccount : onlineAccounts) - if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, this.blockData.getTimestamp())) - return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, null, publicKey, nonce); + onlineAccounts.add(onlineAccountData); } + // Remove those already validated & cached by online accounts manager - no need to re-validate them + OnlineAccountsManager.getInstance().removeKnown(onlineAccounts, onlineTimestamp); + + // Validate the rest + for (OnlineAccountData onlineAccount : onlineAccounts) + if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, null)) + return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + + // Cache the valid online accounts as they will likely be needed for the next block + OnlineAccountsManager.getInstance().addBlocksOnlineAccounts(onlineAccounts, onlineTimestamp); + // Extract online accounts' timestamp signatures from block data. Only one signature if aggregated. List onlineAccountsSignatures = BlockTransformer.decodeTimestampSignatures(encodedOnlineAccountSignatures); @@ -1108,6 +1145,9 @@ public class Block { // All online accounts valid, so save our list of online accounts for potential later use this.cachedOnlineRewardShares = onlineRewardShares; + // Remember that the accounts are valid, to speed up subsequent checks + this.onlineAccountsAlreadyValid = true; + return ValidationResult.OK; } @@ -1212,6 +1252,13 @@ public class Block { || transaction.getDeadline() <= this.blockData.getTimestamp()) return ValidationResult.TRANSACTION_TIMESTAMP_INVALID; + // After feature trigger, check that this transaction is confirmable + if (transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + if (!transaction.isConfirmable()) { + return ValidationResult.TRANSACTION_NOT_CONFIRMABLE; + } + } + // Check transaction isn't already included in a block if (this.repository.getTransactionRepository().isConfirmed(transactionData.getSignature())) return ValidationResult.TRANSACTION_ALREADY_PROCESSED; @@ -1445,6 +1492,9 @@ public class Block { if (this.blockData.getHeight() == 212937) // Apply fix for block 212937 Block212937.processFix(this); + + else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) + SelfSponsorshipAlgoV1Block.processAccountPenalties(this); } // We're about to (test-)process a batch of transactions, @@ -1501,19 +1551,23 @@ public class Block { // Batch update in repository repository.getAccountRepository().modifyMintedBlockCounts(allUniqueExpandedAccounts.stream().map(AccountData::getAddress).collect(Collectors.toList()), +1); + // Keep track of level bumps in case we need to apply to other entries + Map bumpedAccounts = new HashMap<>(); + // Local changes and also checks for level bump for (AccountData accountData : allUniqueExpandedAccounts) { // Adjust count locally (in Java) accountData.setBlocksMinted(accountData.getBlocksMinted() + 1); LOGGER.trace(() -> String.format("Block minter %s up to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""))); - final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment(); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { if (newLevel > accountData.getLevel()) { // Account has increased in level! accountData.setLevel(newLevel); + bumpedAccounts.put(accountData.getAddress(), newLevel); repository.getAccountRepository().setLevel(accountData); LOGGER.trace(() -> String.format("Block minter %s bumped to level %d", accountData.getAddress(), accountData.getLevel())); } @@ -1521,6 +1575,25 @@ public class Block { break; } } + + // Also bump other entries if need be + if (!bumpedAccounts.isEmpty()) { + for (ExpandedAccount expandedAccount : expandedAccounts) { + Integer newLevel = bumpedAccounts.get(expandedAccount.mintingAccountData.getAddress()); + if (newLevel != null && expandedAccount.mintingAccountData.getLevel() != newLevel) { + expandedAccount.mintingAccountData.setLevel(newLevel); + LOGGER.trace("Also bumped {} to level {}", expandedAccount.mintingAccountData.getAddress(), newLevel); + } + + if (!expandedAccount.isRecipientAlsoMinter) { + newLevel = bumpedAccounts.get(expandedAccount.recipientAccountData.getAddress()); + if (newLevel != null && expandedAccount.recipientAccountData.getLevel() != newLevel) { + expandedAccount.recipientAccountData.setLevel(newLevel); + LOGGER.trace("Also bumped {} to level {}", expandedAccount.recipientAccountData.getAddress(), newLevel); + } + } + } + } } protected void processBlockRewards() throws DataException { @@ -1638,12 +1711,14 @@ public class Block { transactionData.getSignature()); this.repository.getBlockRepository().save(blockTransactionData); - // Update transaction's height in repository + // Update transaction's height in repository and local transactionData transactionRepository.updateBlockHeight(transactionData.getSignature(), this.blockData.getHeight()); - - // Update local transactionData's height too transaction.getTransactionData().setBlockHeight(this.blockData.getHeight()); + // Update transaction's sequence in repository and local transactionData + transactionRepository.updateBlockSequence(transactionData.getSignature(), sequence); + transaction.getTransactionData().setBlockSequence(sequence); + // No longer unconfirmed transactionRepository.confirmTransaction(transactionData.getSignature()); @@ -1680,6 +1755,9 @@ public class Block { // Revert fix for block 212937 Block212937.orphanFix(this); + else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) + SelfSponsorshipAlgoV1Block.orphanAccountPenalties(this); + // Block rewards, including transaction fees, removed after transactions undone orphanBlockRewards(); @@ -1727,6 +1805,9 @@ public class Block { // Unset height transactionRepository.updateBlockHeight(transactionData.getSignature(), null); + + // Unset sequence + transactionRepository.updateBlockSequence(transactionData.getSignature(), null); } transactionRepository.deleteParticipants(transactionData); @@ -1808,7 +1889,7 @@ public class Block { accountData.setBlocksMinted(accountData.getBlocksMinted() - 1); LOGGER.trace(() -> String.format("Block minter %s down to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""))); - final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment(); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 42692a18..540e6cf4 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -48,9 +48,6 @@ public class BlockChain { /** Transaction expiry period, starting from transaction's timestamp, in milliseconds. */ private long transactionExpiryPeriod; - @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) - private long unitFee; - private int maxBytesPerUnitFee; /** Maximum acceptable timestamp disagreement offset in milliseconds. */ @@ -73,7 +70,13 @@ public class BlockChain { calcChainWeightTimestamp, transactionV5Timestamp, transactionV6Timestamp, - disableReferenceTimestamp; + disableReferenceTimestamp, + increaseOnlineAccountsDifficultyTimestamp, + onlineAccountMinterLevelValidationHeight, + selfSponsorshipAlgoV1Height, + feeValidationFixTimestamp, + chatReferenceTimestamp, + arbitraryOptionalFeeTimestamp; } // Custom transaction fees @@ -83,6 +86,7 @@ public class BlockChain { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long fee; } + private List unitFees; private List nameRegistrationUnitFees; /** Map of which blockchain features are enabled when (height/timestamp) */ @@ -95,6 +99,13 @@ public class BlockChain { /** Whether only one registered name is allowed per account. */ private boolean oneNamePerAccount = false; + /** Checkpoints */ + public static class Checkpoint { + public int height; + public String signature; + } + private List checkpoints; + /** Block rewards by block height */ public static class RewardByHeight { public int height; @@ -195,9 +206,11 @@ public class BlockChain { * featureTriggers because unit tests need to set this value via Reflection. */ private long onlineAccountsModulusV2Timestamp; - /** Feature trigger timestamp for online accounts mempow verification. Can't use featureTriggers - * because unit tests need to set this value via Reflection. */ - private long onlineAccountsMemoryPoWTimestamp; + /** Snapshot timestamp for self sponsorship algo V1 */ + private long selfSponsorshipAlgoV1SnapshotTimestamp; + + /** Feature-trigger timestamp to modify behaviour of various transactions that support mempow */ + private long mempowTransactionUpdatesTimestamp; /** Max reward shares by block height */ public static class MaxRewardSharesByTimestamp { @@ -334,10 +347,6 @@ public class BlockChain { return this.isTestChain; } - public long getUnitFee() { - return this.unitFee; - } - public int getMaxBytesPerUnitFee() { return this.maxBytesPerUnitFee; } @@ -359,8 +368,14 @@ public class BlockChain { return this.onlineAccountsModulusV2Timestamp; } - public long getOnlineAccountsMemoryPoWTimestamp() { - return this.onlineAccountsMemoryPoWTimestamp; + // Self sponsorship algo + public long getSelfSponsorshipAlgoV1SnapshotTimestamp() { + return this.selfSponsorshipAlgoV1SnapshotTimestamp; + } + + // Feature-trigger timestamp to modify behaviour of various transactions that support mempow + public long getMemPoWTransactionUpdatesTimestamp() { + return this.mempowTransactionUpdatesTimestamp; } /** Returns true if approval-needing transaction types require a txGroupId other than NO_GROUP. */ @@ -376,6 +391,10 @@ public class BlockChain { return this.oneNamePerAccount; } + public List getCheckpoints() { + return this.checkpoints; + } + public List getBlockRewardsByHeight() { return this.rewardsByHeight; } @@ -486,6 +505,30 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.disableReferenceTimestamp.name()).longValue(); } + public long getIncreaseOnlineAccountsDifficultyTimestamp() { + return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); + } + + public int getSelfSponsorshipAlgoV1Height() { + return this.featureTriggers.get(FeatureTrigger.selfSponsorshipAlgoV1Height.name()).intValue(); + } + + public long getOnlineAccountMinterLevelValidationHeight() { + return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue(); + } + + public long getFeeValidationFixTimestamp() { + return this.featureTriggers.get(FeatureTrigger.feeValidationFixTimestamp.name()).longValue(); + } + + public long getChatReferenceTimestamp() { + return this.featureTriggers.get(FeatureTrigger.chatReferenceTimestamp.name()).longValue(); + } + + public long getArbitraryOptionalFeeTimestamp() { + return this.featureTriggers.get(FeatureTrigger.arbitraryOptionalFeeTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp @@ -506,13 +549,22 @@ public class BlockChain { throw new IllegalStateException(String.format("No block timing info available for height %d", ourHeight)); } + public long getUnitFeeAtTimestamp(long ourTimestamp) { + for (int i = unitFees.size() - 1; i >= 0; --i) + if (unitFees.get(i).timestamp <= ourTimestamp) + return unitFees.get(i).fee; + + // Shouldn't happen, but set a sensible default just in case + return 100000; + } + public long getNameRegistrationUnitFeeAtTimestamp(long ourTimestamp) { for (int i = nameRegistrationUnitFees.size() - 1; i >= 0; --i) if (nameRegistrationUnitFees.get(i).timestamp <= ourTimestamp) return nameRegistrationUnitFees.get(i).fee; - // Default to system-wide unit fee - return this.getUnitFee(); + // Shouldn't happen, but set a sensible default just in case + return 100000; } public int getMaxRewardSharesAtTimestamp(long ourTimestamp) { @@ -654,6 +706,7 @@ public class BlockChain { boolean isTopOnly = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + boolean isLite = Settings.getInstance().isLite(); boolean canBootstrap = Settings.getInstance().getBootstrap(); boolean needsArchiveRebuild = false; BlockData chainTip; @@ -674,22 +727,44 @@ public class BlockChain { } } } + + // Validate checkpoints + // Limited to topOnly nodes for now, in order to reduce risk, and to solve a real-world problem with divergent topOnly nodes + // TODO: remove the isTopOnly conditional below once this feature has had more testing time + if (isTopOnly && !isLite) { + List checkpoints = BlockChain.getInstance().getCheckpoints(); + for (Checkpoint checkpoint : checkpoints) { + BlockData blockData = repository.getBlockRepository().fromHeight(checkpoint.height); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(checkpoint.height); + } + if (blockData == null) { + LOGGER.trace("Couldn't find block for height {}", checkpoint.height); + // This is likely due to the block being pruned, so is safe to ignore. + // Continue, as there might be other blocks we can check more definitively. + continue; + } + + byte[] signature = Base58.decode(checkpoint.signature); + if (!Arrays.equals(signature, blockData.getSignature())) { + LOGGER.info("Error: block at height {} with signature {} doesn't match checkpoint sig: {}. Bootstrapping...", checkpoint.height, Base58.encode(blockData.getSignature()), checkpoint.signature); + needsArchiveRebuild = true; + break; + } + LOGGER.info("Block at height {} matches checkpoint signature", blockData.getHeight()); + } + } + } - boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); + // Check first block is Genesis Block + if (!isGenesisBlockValid() || needsArchiveRebuild) { + try { + rebuildBlockchain(); - if (isTopOnly && hasBlocks) { - // Top-only mode is enabled and we have blocks, so it's possible that the genesis block has been pruned - // It's best not to validate it, and there's no real need to - } else { - // Check first block is Genesis Block - if (!isGenesisBlockValid() || needsArchiveRebuild) { - try { - rebuildBlockchain(); - - } catch (InterruptedException e) { - throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); - } + } catch (InterruptedException e) { + throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); } } @@ -698,9 +773,7 @@ public class BlockChain { try (final Repository repository = RepositoryManager.getRepository()) { repository.checkConsistency(); - // Set the number of blocks to validate based on the pruned state of the chain - // If pruned, subtract an extra 10 to allow room for error - int blocksToValidate = (isTopOnly || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; + int blocksToValidate = Math.min(Settings.getInstance().getPruneBlockLimit() - 10, 1440); int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); @@ -809,6 +882,9 @@ public class BlockChain { BlockData orphanBlockData = repository.getBlockRepository().fromHeight(height); while (height > targetHeight) { + if (Controller.isStopping()) { + return false; + } LOGGER.info(String.format("Forcably orphaning block %d", height)); Block block = new Block(repository, orphanBlockData); diff --git a/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java b/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java new file mode 100644 index 00000000..a9a016b6 --- /dev/null +++ b/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java @@ -0,0 +1,133 @@ +package org.qortal.block; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.account.SelfSponsorshipAlgoV1; +import org.qortal.api.model.AccountPenaltyStats; +import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountData; +import org.qortal.data.account.AccountPenaltyData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.utils.Base58; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Self Sponsorship AlgoV1 Block + *

+ * Selected block for the initial run on the "self sponsorship detection algorithm" + */ +public final class SelfSponsorshipAlgoV1Block { + + private static final Logger LOGGER = LogManager.getLogger(SelfSponsorshipAlgoV1Block.class); + + + private SelfSponsorshipAlgoV1Block() { + /* Do not instantiate */ + } + + public static void processAccountPenalties(Block block) throws DataException { + LOGGER.info("Running algo for block processing - this will take a while..."); + logPenaltyStats(block.repository); + long startTime = System.currentTimeMillis(); + Set penalties = getAccountPenalties(block.repository, -5000000); + block.repository.getAccountRepository().updateBlocksMintedPenalties(penalties); + long totalTime = System.currentTimeMillis() - startTime; + String hash = getHash(penalties.stream().map(p -> p.getAddress()).collect(Collectors.toList())); + LOGGER.info("{} penalty addresses processed (hash: {}). Total time taken: {} seconds", penalties.size(), hash, (int)(totalTime / 1000.0f)); + logPenaltyStats(block.repository); + + int updatedCount = updateAccountLevels(block.repository, penalties); + LOGGER.info("Account levels updated for {} penalty addresses", updatedCount); + } + + public static void orphanAccountPenalties(Block block) throws DataException { + LOGGER.info("Running algo for block orphaning - this will take a while..."); + logPenaltyStats(block.repository); + long startTime = System.currentTimeMillis(); + Set penalties = getAccountPenalties(block.repository, 5000000); + block.repository.getAccountRepository().updateBlocksMintedPenalties(penalties); + long totalTime = System.currentTimeMillis() - startTime; + String hash = getHash(penalties.stream().map(p -> p.getAddress()).collect(Collectors.toList())); + LOGGER.info("{} penalty addresses orphaned (hash: {}). Total time taken: {} seconds", penalties.size(), hash, (int)(totalTime / 1000.0f)); + logPenaltyStats(block.repository); + + int updatedCount = updateAccountLevels(block.repository, penalties); + LOGGER.info("Account levels updated for {} penalty addresses", updatedCount); + } + + public static Set getAccountPenalties(Repository repository, int penalty) throws DataException { + final long snapshotTimestamp = BlockChain.getInstance().getSelfSponsorshipAlgoV1SnapshotTimestamp(); + Set penalties = new LinkedHashSet<>(); + List addresses = repository.getTransactionRepository().getConfirmedRewardShareCreatorsExcludingSelfShares(); + for (String address : addresses) { + //System.out.println(String.format("address: %s", address)); + SelfSponsorshipAlgoV1 selfSponsorshipAlgoV1 = new SelfSponsorshipAlgoV1(repository, address, snapshotTimestamp, false); + selfSponsorshipAlgoV1.run(); + //System.out.println(String.format("Penalty addresses: %d", selfSponsorshipAlgoV1.getPenaltyAddresses().size())); + + for (String penaltyAddress : selfSponsorshipAlgoV1.getPenaltyAddresses()) { + penalties.add(new AccountPenaltyData(penaltyAddress, penalty)); + } + } + return penalties; + } + + private static int updateAccountLevels(Repository repository, Set accountPenalties) throws DataException { + final List cumulativeBlocksByLevel = BlockChain.getInstance().getCumulativeBlocksByLevel(); + final int maximumLevel = cumulativeBlocksByLevel.size() - 1; + + int updatedCount = 0; + + for (AccountPenaltyData penaltyData : accountPenalties) { + AccountData accountData = repository.getAccountRepository().getAccount(penaltyData.getAddress()); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); + + // Shortcut for penalties + if (effectiveBlocksMinted < 0) { + accountData.setLevel(0); + repository.getAccountRepository().setLevel(accountData); + updatedCount++; + LOGGER.trace(() -> String.format("Block minter %s dropped to level %d", accountData.getAddress(), accountData.getLevel())); + continue; + } + + for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) { + if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { + accountData.setLevel(newLevel); + repository.getAccountRepository().setLevel(accountData); + updatedCount++; + LOGGER.trace(() -> String.format("Block minter %s increased to level %d", accountData.getAddress(), accountData.getLevel())); + break; + } + } + } + + return updatedCount; + } + + private static void logPenaltyStats(Repository repository) { + try { + LOGGER.info(getPenaltyStats(repository)); + + } catch (DataException e) {} + } + + private static AccountPenaltyStats getPenaltyStats(Repository repository) throws DataException { + List accounts = repository.getAccountRepository().getPenaltyAccounts(); + return AccountPenaltyStats.fromAccounts(accounts); + } + + public static String getHash(List penaltyAddresses) { + if (penaltyAddresses == null || penaltyAddresses.isEmpty()) { + return null; + } + Collections.sort(penaltyAddresses); + return Base58.encode(Crypto.digest(StringUtils.join(penaltyAddresses).getBytes(StandardCharsets.UTF_8))); + } + +} diff --git a/src/main/java/org/qortal/controller/AutoUpdate.java b/src/main/java/org/qortal/controller/AutoUpdate.java index 2ec7c94a..fde52fb1 100644 --- a/src/main/java/org/qortal/controller/AutoUpdate.java +++ b/src/main/java/org/qortal/controller/AutoUpdate.java @@ -293,4 +293,77 @@ public class AutoUpdate extends Thread { } } + public static boolean attemptRestart() { + LOGGER.info(String.format("Restarting node...")); + + // Give repository a chance to backup in case things go badly wrong (if enabled) + if (Settings.getInstance().getRepositoryBackupInterval() > 0) { + try { + // Timeout if the database isn't ready for backing up after 60 seconds + long timeout = 60 * 1000L; + RepositoryManager.backup(true, "backup", timeout); + + } catch (TimeoutException e) { + LOGGER.info("Attempt to backup repository failed due to timeout: {}", e.getMessage()); + // Continue with the node restart anyway... + } + } + + // Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced) + String javaHome = System.getProperty("java.home"); + LOGGER.debug(String.format("Java home: %s", javaHome)); + + Path javaBinary = Paths.get(javaHome, "bin", "java"); + LOGGER.debug(String.format("Java binary: %s", javaBinary)); + + try { + List javaCmd = new ArrayList<>(); + // Java runtime binary itself + javaCmd.add(javaBinary.toString()); + + // JVM arguments + javaCmd.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments()); + + // Disable, but retain, any -agentlib JVM arg as sub-process might fail if it tries to reuse same port + javaCmd = javaCmd.stream() + .map(arg -> arg.replace("-agentlib", AGENTLIB_JVM_HOLDER_ARG)) + .collect(Collectors.toList()); + + // Remove JNI options as they won't be supported by command-line 'java' + // These are typically added by the AdvancedInstaller Java launcher EXE + javaCmd.removeAll(Arrays.asList("abort", "exit", "vfprintf")); + + // Call ApplyUpdate using JAR + javaCmd.addAll(Arrays.asList("-cp", JAR_FILENAME, ApplyUpdate.class.getCanonicalName())); + + // Add command-line args saved from start-up + String[] savedArgs = Controller.getInstance().getSavedArgs(); + if (savedArgs != null) + javaCmd.addAll(Arrays.asList(savedArgs)); + + LOGGER.info(String.format("Restarting node with: %s", String.join(" ", javaCmd))); + + SysTray.getInstance().showMessage(Translator.INSTANCE.translate("SysTray", "AUTO_UPDATE"), //TODO + Translator.INSTANCE.translate("SysTray", "APPLYING_UPDATE_AND_RESTARTING"), //TODO + MessageType.INFO); + + ProcessBuilder processBuilder = new ProcessBuilder(javaCmd); + + // New process will inherit our stdout and stderr + processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT); + processBuilder.redirectError(ProcessBuilder.Redirect.INHERIT); + + Process process = processBuilder.start(); + + // Nothing to pipe to new process, so close output stream (process's stdin) + process.getOutputStream().close(); + + return true; // restarting node OK + } catch (Exception e) { + LOGGER.error(String.format("Failed to restart node: %s", e.getMessage())); + + return true; // repo was okay, even if applying update failed + } + } + } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 100e74db..35c89778 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -26,9 +26,6 @@ import org.qortal.data.block.CommonBlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.network.Network; import org.qortal.network.Peer; -import org.qortal.network.message.BlockSummariesV2Message; -import org.qortal.network.message.HeightV2Message; -import org.qortal.network.message.Message; import org.qortal.repository.BlockRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -38,6 +35,8 @@ import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import org.qortal.utils.NTP; +import static org.junit.Assert.assertNotNull; + // Minting new blocks public class BlockMinter extends Thread { @@ -64,8 +63,8 @@ public class BlockMinter extends Thread { public void run() { Thread.currentThread().setName("BlockMinter"); - if (Settings.getInstance().isLite()) { - // Lite nodes do not mint + if (Settings.getInstance().isTopOnly() || Settings.getInstance().isLite()) { + // Top only and lite nodes do not sign blocks return; } if (Settings.getInstance().getWipeUnconfirmedOnStart()) { @@ -93,6 +92,8 @@ public class BlockMinter extends Thread { List newBlocks = new ArrayList<>(); + final boolean isSingleNodeTestnet = Settings.getInstance().isSingleNodeTestnet(); + try (final Repository repository = RepositoryManager.getRepository()) { // Going to need this a lot... BlockRepository blockRepository = repository.getBlockRepository(); @@ -111,8 +112,9 @@ public class BlockMinter extends Thread { // Free up any repository locks repository.discardChanges(); - // Sleep for a while - Thread.sleep(1000); + // Sleep for a while. + // It's faster on single node testnets, to allow lots of blocks to be minted quickly. + Thread.sleep(isSingleNodeTestnet ? 50 : 1000); isMintingPossible = false; @@ -223,9 +225,10 @@ public class BlockMinter extends Thread { List newBlocksMintingAccounts = mintingAccountsData.stream().map(accountData -> new PrivateKeyAccount(repository, accountData.getPrivateKey())).collect(Collectors.toList()); // We might need to sit the next block out, if one of our minting accounts signed the previous one + // Skip this check for single node testnets, since they definitely need to mint every block byte[] previousBlockMinter = previousBlockData.getMinterPublicKey(); boolean mintedLastBlock = mintingAccountsData.stream().anyMatch(mintingAccount -> Arrays.equals(mintingAccount.getPublicKey(), previousBlockMinter)); - if (mintedLastBlock) { + if (mintedLastBlock && !isSingleNodeTestnet) { LOGGER.trace(String.format("One of our keys signed the last block, so we won't sign the next one")); continue; } @@ -244,7 +247,7 @@ public class BlockMinter extends Thread { Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); if (newBlock == null) { // For some reason we can't mint right now - moderatedLog(() -> LOGGER.error("Couldn't build a to-be-minted block")); + moderatedLog(() -> LOGGER.info("Couldn't build a to-be-minted block")); continue; } @@ -377,9 +380,13 @@ public class BlockMinter extends Thread { parentSignatureForLastLowWeightBlock = null; timeOfLastLowWeightBlock = null; + Long unconfirmedStartTime = NTP.getTime(); + // Add unconfirmed transactions addUnconfirmedTransactions(repository, newBlock); + LOGGER.info(String.format("Adding %d unconfirmed transactions took %d ms", newBlock.getTransactions().size(), (NTP.getTime()-unconfirmedStartTime))); + // Sign to create block's signature newBlock.sign(); @@ -429,6 +436,10 @@ public class BlockMinter extends Thread { // Unable to process block - report and discard LOGGER.error("Unable to process newly minted block?", e); newBlocks.clear(); + } catch (ArithmeticException e) { + // Unable to process block - report and discard + LOGGER.error("Unable to process newly minted block?", e); + newBlocks.clear(); } } finally { blockchainLock.unlock(); @@ -477,6 +488,9 @@ public class BlockMinter extends Thread { // Sign to create block's signature, needed by Block.isValid() newBlock.sign(); + // User-defined limit per block + int limit = Settings.getInstance().getMaxTransactionsPerBlock(); + // Attempt to add transactions until block is full, or we run out // If a transaction makes the block invalid then skip it and it'll either expire or be in next block. for (TransactionData transactionData : unconfirmedTransactions) { @@ -489,6 +503,12 @@ public class BlockMinter extends Thread { LOGGER.debug(() -> String.format("Skipping invalid transaction %s during block minting", Base58.encode(transactionData.getSignature()))); newBlock.deleteTransaction(transactionData); } + + // User-defined limit per block + List transactions = newBlock.getTransactions(); + if (transactions != null && transactions.size() >= limit) { + break; + } } } @@ -507,6 +527,21 @@ public class BlockMinter extends Thread { PrivateKeyAccount mintingAccount = mintingAndOnlineAccounts[0]; + Block block = mintTestingBlockRetainingTimestamps(repository, mintingAccount); + assertNotNull("Minted block must not be null", block); + + return block; + } + + public static Block mintTestingBlockUnvalidated(Repository repository, PrivateKeyAccount... mintingAndOnlineAccounts) throws DataException { + if (!BlockChain.getInstance().isTestChain()) + throw new DataException("Ignoring attempt to mint testing block for non-test chain!"); + + // Ensure mintingAccount is 'online' so blocks can be minted + OnlineAccountsManager.getInstance().ensureTestingAccountsOnline(mintingAndOnlineAccounts); + + PrivateKeyAccount mintingAccount = mintingAndOnlineAccounts[0]; + return mintTestingBlockRetainingTimestamps(repository, mintingAccount); } @@ -514,6 +549,8 @@ public class BlockMinter extends Thread { BlockData previousBlockData = repository.getBlockRepository().getLastBlock(); Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); + if (newBlock == null) + return null; // Make sure we're the only thread modifying the blockchain ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); @@ -525,6 +562,9 @@ public class BlockMinter extends Thread { // Sign to create block's signature newBlock.sign(); + // Ensure online accounts are fully re-validated in this final check + newBlock.clearOnlineAccountsValidationCache(); + // Is newBlock still valid? ValidationResult validationResult = newBlock.isValid(); if (validationResult != ValidationResult.OK) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index ce994757..2cab24ec 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -29,6 +29,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.account.Account; import org.qortal.api.ApiService; import org.qortal.api.DomainMapService; import org.qortal.api.GatewayService; @@ -401,12 +402,11 @@ public class Controller extends Thread { RepositoryManager.setRequestedCheckpoint(Boolean.TRUE); try (final Repository repository = RepositoryManager.getRepository()) { - RepositoryManager.archive(repository); - RepositoryManager.prune(repository); + RepositoryManager.rebuildTransactionSequences(repository); } } catch (DataException e) { - // If exception has no cause then repository is in use by some other process. - if (e.getCause() == null) { + // If exception has no cause or message then repository is in use by some other process. + if (e.getCause() == null && e.getMessage() == null) { LOGGER.info("Repository in use by another process?"); Gui.getInstance().fatalError("Repository issue", "Repository in use by another process?"); } else { @@ -440,6 +440,19 @@ public class Controller extends Thread { } } + try (Repository repository = RepositoryManager.getRepository()) { + if (RepositoryManager.needsTransactionSequenceRebuild(repository)) { + // Don't allow the node to start if transaction sequences haven't been built yet + // This is needed to handle a case when bootstrapping + LOGGER.error("Database upgrade needed. Please restart the core to complete the upgrade process."); + Gui.getInstance().fatalError("Database upgrade needed", "Please restart the core to complete the upgrade process."); + return; + } + } catch (DataException e) { + LOGGER.error("Error checking transaction sequences in repository", e); + return; + } + // Import current trade bot states and minting accounts if they exist Controller.importRepositoryData(); @@ -756,6 +769,28 @@ public class Controller extends Thread { return peer.isAtLeastVersion(minPeerVersion) == false; }; + public static final Predicate hasInvalidSigner = peer -> { + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + if (peerChainTipData == null) + return true; + + try (Repository repository = RepositoryManager.getRepository()) { + return Account.getRewardShareEffectiveMintingLevel(repository, peerChainTipData.getMinterPublicKey()) == 0; + } catch (DataException e) { + return true; + } + }; + + public static final Predicate wasRecentlyTooDivergent = peer -> { + Long now = NTP.getTime(); + Long peerLastTooDivergentTime = peer.getLastTooDivergentTime(); + if (now == null || peerLastTooDivergentTime == null) + return false; + + // Exclude any peers that were TOO_DIVERGENT in the last 5 mins + return (now - peerLastTooDivergentTime < 5 * 60 * 1000L); + }; + private long getRandomRepositoryMaintenanceInterval() { final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval(); final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval(); @@ -838,6 +873,12 @@ public class Controller extends Thread { String tooltip = String.format("%s - %d %s", actionText, numberOfPeers, connectionsText); if (!Settings.getInstance().isLite()) { tooltip = tooltip.concat(String.format(" - %s %d", heightText, height)); + + final Integer blocksRemaining = Synchronizer.getInstance().getBlocksRemaining(); + if (blocksRemaining != null && blocksRemaining > 0) { + String blocksRemainingText = Translator.INSTANCE.translate("SysTray", "BLOCKS_REMAINING"); + tooltip = tooltip.concat(String.format(" - %d %s", blocksRemaining, blocksRemainingText)); + } } tooltip = tooltip.concat(String.format("\n%s: %s", Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"), this.buildVersion)); SysTray.getInstance().setToolTipText(tooltip); @@ -1237,13 +1278,6 @@ public class Controller extends Thread { TransactionImporter.getInstance().onNetworkTransactionSignaturesMessage(peer, message); break; - case GET_ONLINE_ACCOUNTS: - case ONLINE_ACCOUNTS: - case GET_ONLINE_ACCOUNTS_V2: - case ONLINE_ACCOUNTS_V2: - // No longer supported - to be eventually removed - break; - case GET_ONLINE_ACCOUNTS_V3: OnlineAccountsManager.getInstance().onNetworkGetOnlineAccountsV3Message(peer, message); break; @@ -1350,9 +1384,24 @@ public class Controller extends Thread { // If we have no block data, we should check the archive in case it's there if (blockData == null) { if (Settings.getInstance().isArchiveEnabled()) { - byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, true, repository); - if (bytes != null) { - CachedBlockMessage blockMessage = new CachedBlockMessage(bytes); + Triple serializedBlock = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, true, repository); + if (serializedBlock != null) { + byte[] bytes = serializedBlock.getA(); + Integer serializationVersion = serializedBlock.getB(); + + Message blockMessage; + switch (serializationVersion) { + case 1: + blockMessage = new CachedBlockMessage(bytes); + break; + + case 2: + blockMessage = new CachedBlockV2Message(bytes); + break; + + default: + return; + } blockMessage.setId(message.getId()); // This call also causes the other needed data to be pulled in from repository @@ -1603,6 +1652,17 @@ public class Controller extends Thread { } } + if (message.hasId()) { + /* + * Experimental proof-of-concept: discard messages with ID + * These are 'late' reply messages received after timeout has expired, + * having been passed upwards from Peer to Network to Controller. + * Hence, these are NOT simple "here's my chain tip" broadcasts from other peers. + */ + LOGGER.debug("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); + return; + } + // Update peer chain tip data peer.setChainTipSummaries(blockSummariesV2Message.getBlockSummaries()); @@ -1861,6 +1921,10 @@ public class Controller extends Thread { if (latestBlockData == null || latestBlockData.getTimestamp() < minLatestBlockTimestamp) return false; + if (Settings.getInstance().isSingleNodeTestnet()) + // Single node testnets won't have peers, so we can assume up to date from this point + return true; + // Needs a mutable copy of the unmodifiableList List peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); if (peers == null) diff --git a/src/main/java/org/qortal/controller/DevProxyManager.java b/src/main/java/org/qortal/controller/DevProxyManager.java new file mode 100644 index 00000000..a04e87ac --- /dev/null +++ b/src/main/java/org/qortal/controller/DevProxyManager.java @@ -0,0 +1,74 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.api.DevProxyService; +import org.qortal.repository.DataException; +import org.qortal.settings.Settings; + +public class DevProxyManager { + + protected static final Logger LOGGER = LogManager.getLogger(DevProxyManager.class); + + private static DevProxyManager instance; + + private boolean running = false; + + private String sourceHostAndPort = "127.0.0.1:5173"; // Default for React/Vite + + private DevProxyManager() { + + } + + public static DevProxyManager getInstance() { + if (instance == null) + instance = new DevProxyManager(); + + return instance; + } + + public void start() throws DataException { + synchronized(this) { + if (this.running) { + // Already running + return; + } + + LOGGER.info(String.format("Starting developer proxy service on port %d", Settings.getInstance().getDevProxyPort())); + DevProxyService devProxyService = DevProxyService.getInstance(); + devProxyService.start(); + this.running = true; + } + } + + public void stop() { + synchronized(this) { + if (!this.running) { + // Not running + return; + } + + LOGGER.info(String.format("Shutting down developer proxy service")); + DevProxyService devProxyService = DevProxyService.getInstance(); + devProxyService.stop(); + this.running = false; + } + } + + public void setSourceHostAndPort(String sourceHostAndPort) { + this.sourceHostAndPort = sourceHostAndPort; + } + + public String getSourceHostAndPort() { + return this.sourceHostAndPort; + } + + public Integer getPort() { + return Settings.getInstance().getDevProxyPort(); + } + + public boolean isRunning() { + return this.running; + } + +} diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index ff20a8d0..25cace2f 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -64,9 +64,19 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_COMPUTE_INITIAL_SLEEP_INTERVAL = 30 * 1000L; // ms - // MemoryPoW - public final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes - public int POW_DIFFICULTY = 18; // leading zero bits + // MemoryPoW - mainnet + public static final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY_V1 = 18; // leading zero bits + public static final int POW_DIFFICULTY_V2 = 19; // leading zero bits + + // MemoryPoW - testnet + public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY_TESTNET = 5; // leading zero bits + + // IMPORTANT: if we ever need to dynamically modify the buffer size using a feature trigger, the + // pre-allocated buffer below will NOT work, and we should instead use a dynamically allocated + // one for the transition period. + private static long[] POW_VERIFY_WORK_BUFFER = new long[getPoWBufferSize() / 8]; private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts")); private volatile boolean isStopping = false; @@ -112,6 +122,23 @@ public class OnlineAccountsManager { return (timestamp / getOnlineTimestampModulus()) * getOnlineTimestampModulus(); } + private static int getPoWBufferSize() { + if (Settings.getInstance().isTestNet()) + return POW_BUFFER_SIZE_TESTNET; + + return POW_BUFFER_SIZE; + } + + private static int getPoWDifficulty(long timestamp) { + if (Settings.getInstance().isTestNet()) + return POW_DIFFICULTY_TESTNET; + + if (timestamp >= BlockChain.getInstance().getIncreaseOnlineAccountsDifficultyTimestamp()) + return POW_DIFFICULTY_V2; + + return POW_DIFFICULTY_V1; + } + private OnlineAccountsManager() { } @@ -156,7 +183,6 @@ public class OnlineAccountsManager { return; byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); - final boolean mempowActive = onlineAccountsTimestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); Set replacementAccounts = new HashSet<>(); for (PrivateKeyAccount onlineAccount : onlineAccounts) { @@ -165,7 +191,7 @@ public class OnlineAccountsManager { byte[] signature = Qortal25519Extras.signForAggregation(onlineAccount.getPrivateKey(), timestampBytes); byte[] publicKey = onlineAccount.getPublicKey(); - Integer nonce = mempowActive ? new Random().nextInt(500000) : null; + Integer nonce = new Random().nextInt(500000); OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); replacementAccounts.add(ourOnlineAccountData); @@ -192,8 +218,8 @@ public class OnlineAccountsManager { return; // Skip this account if it's already validated - Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountData.getTimestamp(), k -> ConcurrentHashMap.newKeySet()); - if (onlineAccounts.contains(onlineAccountData)) { + Set onlineAccounts = this.currentOnlineAccounts.get(onlineAccountData.getTimestamp()); + if (onlineAccounts != null && onlineAccounts.contains(onlineAccountData)) { // We have already validated this online account onlineAccountsImportQueue.remove(onlineAccountData); continue; @@ -214,8 +240,8 @@ public class OnlineAccountsManager { if (!onlineAccountsToAdd.isEmpty()) { LOGGER.debug("Merging {} validated online accounts from import queue", onlineAccountsToAdd.size()); addAccounts(onlineAccountsToAdd); - onlineAccountsImportQueue.removeAll(onlineAccountsToRemove); } + onlineAccountsImportQueue.removeAll(onlineAccountsToRemove); } } @@ -321,13 +347,10 @@ public class OnlineAccountsManager { return false; } - // Validate mempow if feature trigger is active (or if online account's timestamp is past the trigger timestamp) - long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - if (now >= memoryPoWStartTimestamp || onlineAccountTimestamp >= memoryPoWStartTimestamp) { - if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { - LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); - return false; - } + // Validate mempow + if (!getInstance().verifyMemoryPoW(onlineAccountData, POW_VERIFY_WORK_BUFFER)) { + LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); + return false; } return true; @@ -391,7 +414,7 @@ public class OnlineAccountsManager { boolean isSuperiorEntry = isOnlineAccountsDataSuperior(onlineAccountData); if (isSuperiorEntry) // Remove existing inferior entry so it can be re-added below (it's likely the existing copy is missing a nonce value) - onlineAccounts.remove(onlineAccountData); + onlineAccounts.removeIf(a -> Objects.equals(a.getPublicKey(), onlineAccountData.getPublicKey())); boolean isNewEntry = onlineAccounts.add(onlineAccountData); @@ -471,89 +494,91 @@ public class OnlineAccountsManager { // 'next' timestamp (prioritize this as it's the most important, if mempow active) final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(now) + getOnlineTimestampModulus(); - if (isMemoryPoWActive(now)) { - boolean success = computeOurAccountsForTimestamp(nextOnlineAccountsTimestamp); - if (!success) { - // We didn't compute the required nonce value(s), and so can't proceed until they have been retried - return; - } + boolean success = computeOurAccountsForTimestamp(nextOnlineAccountsTimestamp); + if (!success) { + // We didn't compute the required nonce value(s), and so can't proceed until they have been retried + return; } // 'current' timestamp computeOurAccountsForTimestamp(onlineAccountsTimestamp); } - private boolean computeOurAccountsForTimestamp(long onlineAccountsTimestamp) { - List mintingAccounts; - try (final Repository repository = RepositoryManager.getRepository()) { - mintingAccounts = repository.getAccountRepository().getMintingAccounts(); + private boolean computeOurAccountsForTimestamp(Long onlineAccountsTimestamp) { + if (onlineAccountsTimestamp != null) { + List mintingAccounts; + try (final Repository repository = RepositoryManager.getRepository()) { + mintingAccounts = repository.getAccountRepository().getMintingAccounts(); - // We have no accounts to send - if (mintingAccounts.isEmpty()) + // We have no accounts to send + if (mintingAccounts.isEmpty()) + return false; + + // Only active reward-shares allowed + Iterator iterator = mintingAccounts.iterator(); + int i = 0; + while (iterator.hasNext()) { + MintingAccountData mintingAccountData = iterator.next(); + + RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey()); + if (rewardShareData == null) { + // Reward-share doesn't even exist - probably not a good sign + iterator.remove(); + continue; + } + + Account mintingAccount = new Account(repository, rewardShareData.getMinter()); + if (!mintingAccount.canMint()) { + // Minting-account component of reward-share can no longer mint - disregard + iterator.remove(); + continue; + } + + if (++i > 1 + 1) { + iterator.remove(); + continue; + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage())); return false; + } - // Only active reward-shares allowed - Iterator iterator = mintingAccounts.iterator(); - while (iterator.hasNext()) { - MintingAccountData mintingAccountData = iterator.next(); + byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); + List ourOnlineAccounts = new ArrayList<>(); - RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey()); - if (rewardShareData == null) { - // Reward-share doesn't even exist - probably not a good sign - iterator.remove(); + int remaining = mintingAccounts.size(); + for (MintingAccountData mintingAccountData : mintingAccounts) { + remaining--; + byte[] privateKey = mintingAccountData.getPrivateKey(); + byte[] publicKey = Crypto.toPublicKey(privateKey); + + // We don't want to compute the online account nonce and signature again if it already exists + Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet()); + boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey)); + if (alreadyExists) { + this.hasOurOnlineAccounts = true; + + if (remaining > 0) { + // Move on to next account + continue; + } else { + // Everything exists, so return true + return true; + } + } + + // Generate bytes for mempow + byte[] mempowBytes; + try { + mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp); + } catch (IOException e) { + LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account..."); continue; } - Account mintingAccount = new Account(repository, rewardShareData.getMinter()); - if (!mintingAccount.canMint()) { - // Minting-account component of reward-share can no longer mint - disregard - iterator.remove(); - continue; - } - } - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage())); - return false; - } - - byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); - List ourOnlineAccounts = new ArrayList<>(); - - int remaining = mintingAccounts.size(); - for (MintingAccountData mintingAccountData : mintingAccounts) { - remaining--; - byte[] privateKey = mintingAccountData.getPrivateKey(); - byte[] publicKey = Crypto.toPublicKey(privateKey); - - // We don't want to compute the online account nonce and signature again if it already exists - Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet()); - boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey)); - if (alreadyExists) { - this.hasOurOnlineAccounts = true; - - if (remaining > 0) { - // Move on to next account - continue; - } - else { - // Everything exists, so return true - return true; - } - } - - // Generate bytes for mempow - byte[] mempowBytes; - try { - mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp); - } - catch (IOException e) { - LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account..."); - continue; - } - - // Compute nonce - Integer nonce; - if (isMemoryPoWActive(NTP.getTime())) { + // Compute nonce + Integer nonce; try { nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp); if (nonce == null) { @@ -564,47 +589,39 @@ public class OnlineAccountsManager { LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey))); return false; } - } - else { - // Send -1 if we haven't computed a nonce due to feature trigger timestamp - nonce = -1; + + byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes); + + // Our account is online + OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); + + // Make sure to verify before adding + if (verifyMemoryPoW(ourOnlineAccountData, null)) { + ourOnlineAccounts.add(ourOnlineAccountData); + } } - byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes); + this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty(); - // Our account is online - OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); + boolean hasInfoChanged = addAccounts(ourOnlineAccounts); - // Make sure to verify before adding - if (verifyMemoryPoW(ourOnlineAccountData, NTP.getTime())) { - ourOnlineAccounts.add(ourOnlineAccountData); - } + if (!hasInfoChanged) + return false; + + Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts)); + + LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp); + + return true; } - this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty(); - - boolean hasInfoChanged = addAccounts(ourOnlineAccounts); - - if (!hasInfoChanged) - return false; - - Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts)); - - LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp); - - return true; + return false; } // MemoryPoW - private boolean isMemoryPoWActive(Long timestamp) { - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp() || Settings.getInstance().isOnlineAccountsMemPoWEnabled()) { - return true; - } - return false; - } private byte[] getMemoryPoWBytes(byte[] publicKey, long onlineAccountsTimestamp) throws IOException { byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); @@ -616,11 +633,6 @@ public class OnlineAccountsManager { } private Integer computeMemoryPoW(byte[] bytes, byte[] publicKey, long onlineAccountsTimestamp) throws TimeoutException { - if (!isMemoryPoWActive(NTP.getTime())) { - LOGGER.info("Mempow start timestamp not yet reached, and onlineAccountsMemPoWEnabled not enabled in settings"); - return null; - } - LOGGER.info(String.format("Computing nonce for account %.8s and timestamp %d...", Base58.encode(publicKey), onlineAccountsTimestamp)); // Calculate the time until the next online timestamp and use it as a timeout when computing the nonce @@ -628,7 +640,8 @@ public class OnlineAccountsManager { final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(startTime) + getOnlineTimestampModulus(); long timeUntilNextTimestamp = nextOnlineAccountsTimestamp - startTime; - Integer nonce = MemoryPoW.compute2(bytes, POW_BUFFER_SIZE, POW_DIFFICULTY, timeUntilNextTimestamp); + int difficulty = getPoWDifficulty(onlineAccountsTimestamp); + Integer nonce = MemoryPoW.compute2(bytes, getPoWBufferSize(), difficulty, timeUntilNextTimestamp); double totalSeconds = (NTP.getTime() - startTime) / 1000.0f; int minutes = (int) ((totalSeconds % 3600) / 60); @@ -637,16 +650,15 @@ public class OnlineAccountsManager { LOGGER.info(String.format("Computed nonce for timestamp %d and account %.8s: %d. Buffer size: %d. Difficulty: %d. " + "Time taken: %02d:%02d. Hashrate: %f", onlineAccountsTimestamp, Base58.encode(publicKey), - nonce, POW_BUFFER_SIZE, POW_DIFFICULTY, minutes, seconds, hashRate)); + nonce, getPoWBufferSize(), difficulty, minutes, seconds, hashRate)); return nonce; } - public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, Long timestamp) { - long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - if (timestamp < memoryPoWStartTimestamp && onlineAccountData.getTimestamp() < memoryPoWStartTimestamp) { - // Not active yet, so treat it as valid - return true; + public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, long[] workBuffer) { + // Require a valid nonce value + if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { + return false; } int nonce = onlineAccountData.getNonce(); @@ -659,7 +671,7 @@ public class OnlineAccountsManager { } // Verify the nonce - return MemoryPoW.verify2(mempowBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + return MemoryPoW.verify2(mempowBytes, workBuffer, getPoWBufferSize(), getPoWDifficulty(onlineAccountData.getTimestamp()), nonce); } @@ -697,7 +709,7 @@ public class OnlineAccountsManager { */ // Block::mint() - only wants online accounts with (online) timestamp that matches block's (online) timestamp so they can be added to new block public List getOnlineAccounts(long onlineTimestamp) { - LOGGER.info(String.format("caller's timestamp: %d, our timestamps: %s", onlineTimestamp, String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); + LOGGER.debug(String.format("caller's timestamp: %d, our timestamps: %s", onlineTimestamp, String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); return new ArrayList<>(Set.copyOf(this.currentOnlineAccounts.getOrDefault(onlineTimestamp, Collections.emptySet()))); } @@ -743,11 +755,12 @@ public class OnlineAccountsManager { * Typically called by {@link Block#areOnlineAccountsValid()} */ public void addBlocksOnlineAccounts(Set blocksOnlineAccounts, Long timestamp) { - // We want to add to 'current' in preference if possible - if (this.currentOnlineAccounts.containsKey(timestamp)) { - addAccounts(blocksOnlineAccounts); + // If these are current accounts, then there is no need to cache them, and should instead rely + // on the more complete entries we already have in self.currentOnlineAccounts. + // Note: since sig-agg, we no longer have individual signatures included in blocks, so we + // mustn't add anything to currentOnlineAccounts from here. + if (this.currentOnlineAccounts.containsKey(timestamp)) return; - } // Add to block cache instead this.latestBlocksOnlineAccounts.computeIfAbsent(timestamp, k -> ConcurrentHashMap.newKeySet()) diff --git a/src/main/java/org/qortal/controller/PirateChainWalletController.java b/src/main/java/org/qortal/controller/PirateChainWalletController.java index 333c2cda..90e65329 100644 --- a/src/main/java/org/qortal/controller/PirateChainWalletController.java +++ b/src/main/java/org/qortal/controller/PirateChainWalletController.java @@ -163,7 +163,7 @@ public class PirateChainWalletController extends Thread { // Library not found, so check if we've fetched the resource from QDN ArbitraryTransactionData t = this.getTransactionData(repository); - if (t == null) { + if (t == null || t.getService() == null) { // Can't find the transaction - maybe on a different chain? return; } diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index ccb3dfdd..804bacbb 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -8,7 +8,6 @@ import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; -import java.util.stream.IntStream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -44,7 +43,7 @@ public class Synchronizer extends Thread { private static final int SYNC_BATCH_SIZE = 1000; // XXX move to Settings? /** Initial jump back of block height when searching for common block with peer */ - private static final int INITIAL_BLOCK_STEP = 7; + private static final int INITIAL_BLOCK_STEP = 8; /** Maximum jump back of block height when searching for common block with peer */ private static final int MAXIMUM_BLOCK_STEP = 128; @@ -54,7 +53,8 @@ public class Synchronizer extends Thread { /** Maximum number of block signatures we ask from peer in one go */ private static final int MAXIMUM_REQUEST_SIZE = 200; // XXX move to Settings? - private static final long RECOVERY_MODE_TIMEOUT = 10 * 60 * 1000L; // ms + /** Maximum number of consecutive failed sync attempts before marking peer as misbehaved */ + private static final int MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS = 3; private boolean running; @@ -76,6 +76,8 @@ public class Synchronizer extends Thread { private volatile boolean isSynchronizing = false; /** Temporary estimate of synchronization progress for SysTray use. */ private volatile int syncPercent = 0; + /** Temporary estimate of blocks remaining for SysTray use. */ + private volatile int blocksRemaining = 0; private static volatile boolean requestSync = false; private boolean syncRequestPending = false; @@ -181,6 +183,18 @@ public class Synchronizer extends Thread { } } + public Integer getBlocksRemaining() { + synchronized (this.syncLock) { + // Report as 0 blocks remaining if the latest block is within the last 60 mins + final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); + if (Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) { + return 0; + } + + return this.isSynchronizing ? this.blocksRemaining : null; + } + } + public void requestSync() { requestSync = true; } @@ -215,13 +229,6 @@ public class Synchronizer extends Thread { peers.removeIf(Controller.hasOldVersion); checkRecoveryModeForPeers(peers); - if (recoveryMode) { - // Needs a mutable copy of the unmodifiableList - peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); - peers.removeIf(Controller.hasOnlyGenesisBlock); - peers.removeIf(Controller.hasMisbehaved); - peers.removeIf(Controller.hasOldVersion); - } // Check we have enough peers to potentially synchronize if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) @@ -233,6 +240,9 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); + // Disregard peers that have a block with an invalid signer + peers.removeIf(Controller.hasInvalidSigner); + final int peersBeforeComparison = peers.size(); // Request recent block summaries from the remaining peers, and locate our common block with each @@ -245,10 +255,7 @@ public class Synchronizer extends Thread { peers.removeIf(Controller.hasInferiorChainTip); // Remove any peers that are no longer on a recent block since the last check - // Except for times when we're in recovery mode, in which case we need to keep them - if (!recoveryMode) { - peers.removeIf(Controller.hasNoRecentBlock); - } + peers.removeIf(Controller.hasNoRecentBlock); final int peersRemoved = peersBeforeComparison - peers.size(); if (peersRemoved > 0 && peers.size() > 0) @@ -397,9 +404,10 @@ public class Synchronizer extends Thread { timePeersLastAvailable = NTP.getTime(); // If enough time has passed, enter recovery mode, which lifts some restrictions on who we can sync with and when we can mint - if (NTP.getTime() - timePeersLastAvailable > RECOVERY_MODE_TIMEOUT) { + long recoveryModeTimeout = Settings.getInstance().getRecoveryModeTimeout(); + if (NTP.getTime() - timePeersLastAvailable > recoveryModeTimeout) { if (recoveryMode == false) { - LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", RECOVERY_MODE_TIMEOUT/60/1000)); + LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", recoveryModeTimeout/60/1000)); recoveryMode = true; } } @@ -1103,6 +1111,7 @@ public class Synchronizer extends Thread { // If common block is too far behind us then we're on massively different forks so give up. if (!force && testHeight < ourHeight - MAXIMUM_COMMON_DELTA) { LOGGER.info(String.format("Blockchain too divergent with peer %s", peer)); + peer.setLastTooDivergentTime(NTP.getTime()); return SynchronizationResult.TOO_DIVERGENT; } @@ -1112,6 +1121,9 @@ public class Synchronizer extends Thread { testHeight = Math.max(testHeight - step, 1); } + // Peer not considered too divergent + peer.setLastTooDivergentTime(0L); + // Prepend test block's summary as first block summary, as summaries returned are *after* test block BlockSummaryData testBlockSummary = new BlockSummaryData(testBlockData); blockSummariesFromCommon.add(0, testBlockSummary); @@ -1318,8 +1330,8 @@ public class Synchronizer extends Thread { return SynchronizationResult.INVALID_DATA; } - // Final check to make sure the peer isn't out of date (except for when we're in recovery mode) - if (!recoveryMode && peer.getChainTipData() != null) { + // Final check to make sure the peer isn't out of date + if (peer.getChainTipData() != null) { final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); final Long peerLastBlockTimestamp = peer.getChainTipData().getTimestamp(); if (peerLastBlockTimestamp == null || peerLastBlockTimestamp < minLatestBlockTimestamp) { @@ -1456,6 +1468,12 @@ public class Synchronizer extends Thread { repository.saveChanges(); + synchronized (this.syncLock) { + if (peer.getChainTipData() != null) { + this.blocksRemaining = peer.getChainTipData().getHeight() - newBlock.getBlockData().getHeight(); + } + } + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } @@ -1551,47 +1569,19 @@ public class Synchronizer extends Thread { repository.saveChanges(); + synchronized (this.syncLock) { + if (peer.getChainTipData() != null) { + this.blocksRemaining = peer.getChainTipData().getHeight() - newBlock.getBlockData().getHeight(); + } + } + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } return SynchronizationResult.OK; } - private List getBlockSummariesFromCache(Peer peer, byte[] parentSignature, int numberRequested) { - List peerSummaries = peer.getChainTipSummaries(); - if (peerSummaries == null) - return null; - - // Check if the requested parent block exists in peer's summaries cache - int parentIndex = IntStream.range(0, peerSummaries.size()).filter(i -> Arrays.equals(peerSummaries.get(i).getSignature(), parentSignature)).findFirst().orElse(-1); - if (parentIndex < 0) - return null; - - // Peer's summaries contains the requested parent, so return summaries after that - // Make sure we have at least one block after the parent block - int summariesAvailable = peerSummaries.size() - parentIndex - 1; - if (summariesAvailable <= 0) - return null; - - // Don't try and return more summaries than we have, or more than were requested - int summariesToReturn = Math.min(numberRequested, summariesAvailable); - int startIndex = parentIndex + 1; - int endIndex = startIndex + summariesToReturn - 1; - if (endIndex > peerSummaries.size() - 1) - return null; - - LOGGER.trace("Serving {} block summaries from cache", summariesToReturn); - return peerSummaries.subList(startIndex, endIndex); - } - private List getBlockSummaries(Peer peer, byte[] parentSignature, int numberRequested) throws InterruptedException { - // We might be able to shortcut the response if we already have the summaries in the peer's chain tip data - List cachedSummaries = this.getBlockSummariesFromCache(peer, parentSignature, numberRequested); - if (cachedSummaries != null && !cachedSummaries.isEmpty()) - return cachedSummaries; - - LOGGER.trace("Requesting {} block summaries from peer {}", numberRequested, peer); - Message getBlockSummariesMessage = new GetBlockSummariesMessage(parentSignature, numberRequested); Message message = peer.getResponse(getBlockSummariesMessage); @@ -1626,8 +1616,20 @@ public class Synchronizer extends Thread { Message getBlockMessage = new GetBlockMessage(signature); Message message = peer.getResponse(getBlockMessage); - if (message == null) + if (message == null) { + peer.getPeerData().incrementFailedSyncCount(); + if (peer.getPeerData().getFailedSyncCount() >= MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS) { + // Several failed attempts, so mark peer as misbehaved + LOGGER.info("Marking peer {} as misbehaved due to {} failed sync attempts", peer, peer.getPeerData().getFailedSyncCount()); + Network.getInstance().peerMisbehaved(peer); + } return null; + } + + // Reset failed sync count now that we have a block response + // FUTURE: we could move this to the end of the sync process, but to reduce risk this can be done + // at a later stage. For now we are only defending against serialization errors or no responses. + peer.getPeerData().setFailedSyncCount(0); switch (message.getType()) { case BLOCK: { diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index 5c70f369..6c846f3b 100644 --- a/src/main/java/org/qortal/controller/TransactionImporter.java +++ b/src/main/java/org/qortal/controller/TransactionImporter.java @@ -47,6 +47,9 @@ public class TransactionImporter extends Thread { /** Map of recent invalid unconfirmed transactions. Key is base58 transaction signature, value is do-not-request expiry timestamp. */ private final Map invalidUnconfirmedTransactions = Collections.synchronizedMap(new HashMap<>()); + /** Cached list of unconfirmed transactions, used when counting per creator. This is replaced regularly */ + public static List unconfirmedTransactionsCache = null; + public static synchronized TransactionImporter getInstance() { if (instance == null) { @@ -215,12 +218,6 @@ public class TransactionImporter extends Thread { LOGGER.debug("Finished validating signatures in incoming transactions queue (valid this round: {}, total pending import: {})...", validatedCount, sigValidTransactions.size()); } - if (!newlyValidSignatures.isEmpty()) { - LOGGER.debug("Broadcasting {} newly valid signatures ahead of import", newlyValidSignatures.size()); - Message newTransactionSignatureMessage = new TransactionSignaturesMessage(newlyValidSignatures); - Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage); - } - } catch (DataException e) { LOGGER.error("Repository issue while processing incoming transactions", e); } @@ -254,6 +251,15 @@ public class TransactionImporter extends Thread { int processedCount = 0; try (final Repository repository = RepositoryManager.getRepository()) { + // Use a single copy of the unconfirmed transactions list for each cycle, to speed up constant lookups + // when counting unconfirmed transactions by creator. + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + unconfirmedTransactions.removeIf(t -> t.getType() == Transaction.TransactionType.CHAT); + unconfirmedTransactionsCache = unconfirmedTransactions; + + // A list of signatures were imported in this round + List newlyImportedSignatures = new ArrayList<>(); + // Import transactions with valid signatures try { for (int i = 0; i < sigValidTransactions.size(); ++i) { @@ -286,6 +292,15 @@ public class TransactionImporter extends Thread { case OK: { LOGGER.debug(() -> String.format("Imported %s transaction %s", transactionData.getType().name(), Base58.encode(transactionData.getSignature()))); + + // Add to the unconfirmed transactions cache + if (transactionData.getType() != Transaction.TransactionType.CHAT && unconfirmedTransactionsCache != null) { + unconfirmedTransactionsCache.add(transactionData); + } + + // Signature imported in this round + newlyImportedSignatures.add(transactionData.getSignature()); + break; } @@ -314,9 +329,18 @@ public class TransactionImporter extends Thread { // Transaction has been processed, even if only to reject it removeIncomingTransaction(transactionData.getSignature()); } + + if (!newlyImportedSignatures.isEmpty()) { + LOGGER.debug("Broadcasting {} newly imported signatures", newlyImportedSignatures.size()); + Message newTransactionSignatureMessage = new TransactionSignaturesMessage(newlyImportedSignatures); + Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage); + } } finally { LOGGER.debug("Finished importing {} incoming transaction{}", processedCount, (processedCount == 1 ? "" : "s")); blockchainLock.unlock(); + + // Clear the unconfirmed transaction cache so new data can be populated in the next cycle + unconfirmedTransactionsCache = null; } } catch (DataException e) { LOGGER.error("Repository issue while importing incoming transactions", e); diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java index 39425b7e..e0c62acb 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java @@ -11,10 +11,7 @@ import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; -import org.qortal.utils.ArbitraryTransactionUtils; -import org.qortal.utils.Base58; -import org.qortal.utils.FilesystemUtils; -import org.qortal.utils.NTP; +import org.qortal.utils.*; import java.io.File; import java.io.IOException; @@ -137,7 +134,7 @@ public class ArbitraryDataCleanupManager extends Thread { // Fetch the transaction data ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); - if (arbitraryTransactionData == null) { + if (arbitraryTransactionData == null || arbitraryTransactionData.getService() == null) { continue; } @@ -204,7 +201,7 @@ public class ArbitraryDataCleanupManager extends Thread { if (completeFileExists && !allChunksExist) { // We have the complete file but not the chunks, so let's convert it - LOGGER.info(String.format("Transaction %s has complete file but no chunks", + LOGGER.debug(String.format("Transaction %s has complete file but no chunks", Base58.encode(arbitraryTransactionData.getSignature()))); ArbitraryTransactionUtils.convertFileToChunks(arbitraryTransactionData, now, STALE_FILE_TIMEOUT); @@ -239,7 +236,7 @@ public class ArbitraryDataCleanupManager extends Thread { // Delete random data associated with name if we're over our storage limit for this name // Use the DELETION_THRESHOLD, for the same reasons as above - for (String followedName : storageManager.followedNames()) { + for (String followedName : ListUtils.followedNames()) { if (isStopping) { return; } @@ -349,6 +346,10 @@ public class ArbitraryDataCleanupManager extends Thread { /** * Iteratively walk through given directory and delete a single random file * + * TODO: public data should be prioritized over private data + * (unless this node is part of a data market contract for that data). + * See: Service.privateServices() for a list of services containing private data. + * * @param directory - the base directory * @return boolean - whether a file was deleted */ @@ -487,7 +488,7 @@ public class ArbitraryDataCleanupManager extends Thread { // Delete data relating to blocked names String name = directory.getName(); - if (name != null && storageManager.isNameBlocked(name)) { + if (name != null && ListUtils.isNameBlocked(name)) { this.safeDeleteDirectory(directory, "blocked name"); } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java index 63a6df80..5ed8df21 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java @@ -20,6 +20,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.Base58; +import org.qortal.utils.ListUtils; import org.qortal.utils.NTP; import org.qortal.utils.Triple; @@ -123,29 +124,29 @@ public class ArbitraryDataFileListManager { } } - // Then allow another 3 attempts, each 5 minutes apart - if (timeSinceLastAttempt > 5 * 60 * 1000L) { - // We haven't tried for at least 5 minutes + // Then allow another 5 attempts, each 1 minute apart + if (timeSinceLastAttempt > 60 * 1000L) { + // We haven't tried for at least 1 minute - if (networkBroadcastCount < 6) { - // We've made less than 6 total attempts + if (networkBroadcastCount < 8) { + // We've made less than 8 total attempts return true; } } - // Then allow another 4 attempts, each 30 minutes apart - if (timeSinceLastAttempt > 30 * 60 * 1000L) { - // We haven't tried for at least 5 minutes + // Then allow another 8 attempts, each 15 minutes apart + if (timeSinceLastAttempt > 15 * 60 * 1000L) { + // We haven't tried for at least 15 minutes - if (networkBroadcastCount < 10) { - // We've made less than 10 total attempts + if (networkBroadcastCount < 16) { + // We've made less than 16 total attempts return true; } } - // From then on, only try once every 24 hours, to reduce network spam - if (timeSinceLastAttempt > 24 * 60 * 60 * 1000L) { - // We haven't tried for at least 24 hours + // From then on, only try once every 6 hours, to reduce network spam + if (timeSinceLastAttempt > 6 * 60 * 60 * 1000L) { + // We haven't tried for at least 6 hours return true; } @@ -258,8 +259,6 @@ public class ArbitraryDataFileListManager { // Lookup file lists by signature (and optionally hashes) public boolean fetchArbitraryDataFileList(ArbitraryTransactionData arbitraryTransactionData) { - byte[] digest = arbitraryTransactionData.getData(); - byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); byte[] signature = arbitraryTransactionData.getSignature(); String signature58 = Base58.encode(signature); @@ -286,8 +285,7 @@ public class ArbitraryDataFileListManager { // Find hashes that we are missing try { - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); missingHashes = arbitraryDataFile.missingHashes(); } catch (DataException e) { // Leave missingHashes as null, so that all hashes are requested @@ -460,10 +458,9 @@ public class ArbitraryDataFileListManager { arbitraryTransactionData = (ArbitraryTransactionData) transactionData; - // Load data file(s) - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(arbitraryTransactionData.getData(), signature); - arbitraryDataFile.setMetadataHash(arbitraryTransactionData.getMetadataHash()); - +// // Load data file(s) +// ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); +// // // Check all hashes exist // for (byte[] hash : hashes) { // //LOGGER.debug("Received hash {}", Base58.encode(hash)); @@ -507,7 +504,7 @@ public class ArbitraryDataFileListManager { // Forwarding if (isRelayRequest && Settings.getInstance().isRelayModeEnabled()) { - boolean isBlocked = (arbitraryTransactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(arbitraryTransactionData.getName())); + boolean isBlocked = (arbitraryTransactionData == null || ListUtils.isNameBlocked(arbitraryTransactionData.getName())); if (!isBlocked) { Peer requestingPeer = request.getB(); if (requestingPeer != null) { @@ -594,12 +591,8 @@ public class ArbitraryDataFileListManager { // Check if we're even allowed to serve data for this transaction if (ArbitraryDataStorageManager.getInstance().canStoreData(transactionData)) { - byte[] hash = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - // Load file(s) and add any that exist to the list of hashes - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); // If the peer didn't supply a hash list, we need to return all hashes for this transaction if (requestedHashes == null || requestedHashes.isEmpty()) { @@ -690,7 +683,7 @@ public class ArbitraryDataFileListManager { } // We may need to forward this request on - boolean isBlocked = (transactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName())); + boolean isBlocked = (transactionData == null || ListUtils.isNameBlocked(transactionData.getName())); if (Settings.getInstance().isRelayModeEnabled() && !isBlocked) { // In relay mode - so ask our other peers if they have it diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index 30b0fcca..48c41496 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -82,7 +82,7 @@ public class ArbitraryDataFileManager extends Thread { try { // Use a fixed thread pool to execute the arbitrary data file requests - int threadCount = 10; + int threadCount = 5; ExecutorService arbitraryDataFileRequestExecutor = Executors.newFixedThreadPool(threadCount); for (int i = 0; i < threadCount; i++) { arbitraryDataFileRequestExecutor.execute(new ArbitraryDataFileRequestThread()); @@ -132,9 +132,7 @@ public class ArbitraryDataFileManager extends Thread { List hashes) throws DataException { // Load data file(s) - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(arbitraryTransactionData.getData(), signature); - byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); boolean receivedAtLeastOneFile = false; // Now fetch actual data from this peer @@ -148,10 +146,10 @@ public class ArbitraryDataFileManager extends Thread { if (!arbitraryDataFileRequests.containsKey(Base58.encode(hash))) { LOGGER.debug("Requesting data file {} from peer {}", hash58, peer); Long startTime = NTP.getTime(); - ArbitraryDataFileMessage receivedArbitraryDataFileMessage = fetchArbitraryDataFile(peer, null, signature, hash, null); + ArbitraryDataFile receivedArbitraryDataFile = fetchArbitraryDataFile(peer, null, signature, hash, null); Long endTime = NTP.getTime(); - if (receivedArbitraryDataFileMessage != null && receivedArbitraryDataFileMessage.getArbitraryDataFile() != null) { - LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFileMessage.getArbitraryDataFile().getHash58(), peer, (endTime-startTime)); + if (receivedArbitraryDataFile != null) { + LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFile.getHash58(), peer, (endTime-startTime)); receivedAtLeastOneFile = true; // Remove this hash from arbitraryDataFileHashResponses now that we have received it @@ -193,11 +191,11 @@ public class ArbitraryDataFileManager extends Thread { return receivedAtLeastOneFile; } - private ArbitraryDataFileMessage fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException { + private ArbitraryDataFile fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException { ArbitraryDataFile existingFile = ArbitraryDataFile.fromHash(hash, signature); boolean fileAlreadyExists = existingFile.exists(); String hash58 = Base58.encode(hash); - ArbitraryDataFileMessage arbitraryDataFileMessage; + ArbitraryDataFile arbitraryDataFile; // Fetch the file if it doesn't exist locally if (!fileAlreadyExists) { @@ -227,28 +225,32 @@ public class ArbitraryDataFileManager extends Thread { } ArbitraryDataFileMessage peersArbitraryDataFileMessage = (ArbitraryDataFileMessage) response; - arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, peersArbitraryDataFileMessage.getArbitraryDataFile()); + arbitraryDataFile = peersArbitraryDataFileMessage.getArbitraryDataFile(); } else { LOGGER.debug(String.format("File hash %s already exists, so skipping the request", hash58)); - arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, existingFile); + arbitraryDataFile = existingFile; + } + + if (arbitraryDataFile == null) { + // We don't have a file, so give up here + return null; } // We might want to forward the request to the peer that originally requested it - this.handleArbitraryDataFileForwarding(requestingPeer, arbitraryDataFileMessage, originalMessage); + this.handleArbitraryDataFileForwarding(requestingPeer, new ArbitraryDataFileMessage(signature, arbitraryDataFile), originalMessage); boolean isRelayRequest = (requestingPeer != null); if (isRelayRequest) { if (!fileAlreadyExists) { // File didn't exist locally before the request, and it's a forwarding request, so delete it LOGGER.debug("Deleting file {} because it was needed for forwarding only", Base58.encode(hash)); - ArbitraryDataFile dataFile = arbitraryDataFileMessage.getArbitraryDataFile(); // Keep trying to delete the data until it is deleted, or we reach 10 attempts - dataFile.delete(10); + arbitraryDataFile.delete(10); } } - return arbitraryDataFileMessage; + return arbitraryDataFile; } private void handleFileListRequests(byte[] signature) { @@ -288,7 +290,7 @@ public class ArbitraryDataFileManager extends Thread { // The ID needs to match that of the original request message.setId(originalMessage.getId()); - if (!requestingPeer.sendMessage(message)) { + if (!requestingPeer.sendMessageWithTimeout(message, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) { LOGGER.debug("Failed to forward arbitrary data file to peer {}", requestingPeer); requestingPeer.disconnect("failed to forward arbitrary data file"); } @@ -564,13 +566,16 @@ public class ArbitraryDataFileManager extends Thread { LOGGER.trace("Hash {} exists", hash58); // We can serve the file directly as we already have it + LOGGER.debug("Sending file {}...", arbitraryDataFile); ArbitraryDataFileMessage arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, arbitraryDataFile); arbitraryDataFileMessage.setId(message.getId()); - if (!peer.sendMessage(arbitraryDataFileMessage)) { - LOGGER.debug("Couldn't sent file"); + if (!peer.sendMessageWithTimeout(arbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) { + LOGGER.debug("Couldn't send file {}", arbitraryDataFile); peer.disconnect("failed to send file"); } - LOGGER.debug("Sent file {}", arbitraryDataFile); + else { + LOGGER.debug("Sent file {}", arbitraryDataFile); + } } else if (relayInfo != null) { LOGGER.debug("We have relay info for hash {}", Base58.encode(hash)); diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java index 2d1beadc..654c6844 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java @@ -114,7 +114,7 @@ public class ArbitraryDataFileRequestThread implements Runnable { return; } - LOGGER.debug("Fetching file {} from peer {} via request thread...", hash58, peer); + LOGGER.trace("Fetching file {} from peer {} via request thread...", hash58, peer); arbitraryDataFileManager.fetchArbitraryDataFiles(repository, peer, signature, arbitraryTransactionData, Arrays.asList(hash)); } catch (DataException e) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java index 6b3f0160..470fbda9 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java @@ -16,7 +16,6 @@ import org.qortal.arbitrary.misc.Service; import org.qortal.controller.Controller; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.TransactionData; -import org.qortal.list.ResourceListManager; import org.qortal.network.Network; import org.qortal.network.Peer; import org.qortal.repository.DataException; @@ -27,6 +26,7 @@ import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.ArbitraryTransactionUtils; import org.qortal.utils.Base58; +import org.qortal.utils.ListUtils; import org.qortal.utils.NTP; public class ArbitraryDataManager extends Thread { @@ -172,7 +172,7 @@ public class ArbitraryDataManager extends Thread { private void processNames() throws InterruptedException { // Fetch latest list of followed names - List followedNames = ResourceListManager.getInstance().getStringsInList("followedNames"); + List followedNames = ListUtils.followedNames(); if (followedNames == null || followedNames.isEmpty()) { return; } @@ -275,7 +275,10 @@ public class ArbitraryDataManager extends Thread { int offset = 0; while (!isStopping) { - Thread.sleep(1000L); + final int minSeconds = 3; + final int maxSeconds = 10; + final int randomSleepTime = new Random().nextInt((maxSeconds - minSeconds + 1)) + minSeconds; + Thread.sleep(randomSleepTime * 1000L); // Any arbitrary transactions we want to fetch data for? try (final Repository repository = RepositoryManager.getRepository()) { @@ -398,6 +401,11 @@ public class ArbitraryDataManager extends Thread { // Entrypoint to request new metadata from peers public ArbitraryDataTransactionMetadata fetchMetadata(ArbitraryTransactionData arbitraryTransactionData) { + if (arbitraryTransactionData.getService() == null) { + // Can't fetch metadata without a valid service + return null; + } + ArbitraryDataResource resource = new ArbitraryDataResource( arbitraryTransactionData.getName(), ArbitraryDataFile.ResourceIdType.NAME, @@ -489,7 +497,7 @@ public class ArbitraryDataManager extends Thread { public void invalidateCache(ArbitraryTransactionData arbitraryTransactionData) { String signature58 = Base58.encode(arbitraryTransactionData.getSignature()); - if (arbitraryTransactionData.getName() != null) { + if (arbitraryTransactionData.getName() != null && arbitraryTransactionData.getService() != null) { String resourceId = arbitraryTransactionData.getName().toLowerCase(); Service service = arbitraryTransactionData.getService(); String identifier = arbitraryTransactionData.getIdentifier(); diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java index 4568d3fd..f6b2dc0a 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java @@ -5,15 +5,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.TransactionData; -import org.qortal.list.ResourceListManager; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.settings.Settings; import org.qortal.transaction.Transaction; -import org.qortal.utils.ArbitraryTransactionUtils; -import org.qortal.utils.Base58; -import org.qortal.utils.FilesystemUtils; -import org.qortal.utils.NTP; +import org.qortal.utils.*; import java.io.IOException; import java.io.UncheckedIOException; @@ -48,7 +44,6 @@ public class ArbitraryDataStorageManager extends Thread { private List hostedTransactions; private String searchQuery; - private List searchResultsTransactions; private static final long DIRECTORY_SIZE_CHECK_INTERVAL = 10 * 60 * 1000L; // 10 minutes @@ -62,6 +57,8 @@ public class ArbitraryDataStorageManager extends Thread { * This must be higher than STORAGE_FULL_THRESHOLD in order to avoid a fetch/delete loop. */ public static final double DELETION_THRESHOLD = 0.98f; // 98% + private static final long PER_NAME_STORAGE_MULTIPLIER = 4L; + public ArbitraryDataStorageManager() { } @@ -136,11 +133,11 @@ public class ArbitraryDataStorageManager extends Thread { case ALL: case VIEWED: // If the policy includes viewed data, we can host it as long as it's not blocked - return !this.isNameBlocked(name); + return !ListUtils.isNameBlocked(name); case FOLLOWED: // If the policy is for followed data only, we have to be following it - return this.isFollowingName(name); + return ListUtils.isFollowingName(name); // For NONE or all else, we shouldn't host this data case NONE: @@ -189,14 +186,14 @@ public class ArbitraryDataStorageManager extends Thread { } // Never fetch data from blocked names, even if they are followed - if (this.isNameBlocked(name)) { + if (ListUtils.isNameBlocked(name)) { return false; } switch (Settings.getInstance().getStoragePolicy()) { case FOLLOWED: case FOLLOWED_OR_VIEWED: - return this.isFollowingName(name); + return ListUtils.isFollowingName(name); case ALL: return true; @@ -236,7 +233,7 @@ public class ArbitraryDataStorageManager extends Thread { * @return boolean - whether the resource is blocked or not */ public boolean isBlocked(ArbitraryTransactionData arbitraryTransactionData) { - return isNameBlocked(arbitraryTransactionData.getName()); + return ListUtils.isNameBlocked(arbitraryTransactionData.getName()); } private boolean isDataTypeAllowed(ArbitraryTransactionData arbitraryTransactionData) { @@ -254,22 +251,6 @@ public class ArbitraryDataStorageManager extends Thread { return true; } - public boolean isNameBlocked(String name) { - return ResourceListManager.getInstance().listContains("blockedNames", name, false); - } - - private boolean isFollowingName(String name) { - return ResourceListManager.getInstance().listContains("followedNames", name, false); - } - - public List followedNames() { - return ResourceListManager.getInstance().getStringsInList("followedNames"); - } - - private int followedNamesCount() { - return ResourceListManager.getInstance().getItemCountForList("followedNames"); - } - public List loadAllHostedTransactions(Repository repository) { @@ -344,11 +325,6 @@ public class ArbitraryDataStorageManager extends Thread { */ public List searchHostedTransactions(Repository repository, String query, Integer limit, Integer offset) { - // Load from results cache if we can (results that exists for the same query), to avoid disk reads - if (this.searchResultsTransactions != null && this.searchQuery.equals(query.toLowerCase())) { - return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset); - } - // Using cache if we can, to avoid disk reads if (this.hostedTransactions == null) { this.hostedTransactions = this.loadAllHostedTransactions(repository); @@ -376,10 +352,7 @@ public class ArbitraryDataStorageManager extends Thread { // Sort by newest first searchResultsList.sort(Comparator.comparingLong(ArbitraryTransactionData::getTimestamp).reversed()); - // Update cache - this.searchResultsTransactions = searchResultsList; - - return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset); + return ArbitraryTransactionUtils.limitOffsetTransactions(searchResultsList, limit, offset); } /** @@ -517,12 +490,17 @@ public class ArbitraryDataStorageManager extends Thread { return false; } + if (Settings.getInstance().getStoragePolicy() == StoragePolicy.ALL) { + // Using storage policy ALL, so don't limit anything per name + return true; + } + if (name == null) { // This transaction doesn't have a name, so fall back to total space limitations return true; } - int followedNamesCount = this.followedNamesCount(); + int followedNamesCount = ListUtils.followedNamesCount(); if (followedNamesCount == 0) { // Not following any names, so we have space return true; @@ -552,14 +530,16 @@ public class ArbitraryDataStorageManager extends Thread { } public long storageCapacityPerName(double threshold) { - int followedNamesCount = this.followedNamesCount(); + int followedNamesCount = ListUtils.followedNamesCount(); if (followedNamesCount == 0) { // Not following any names, so we have the total space available return this.getStorageCapacityIncludingThreshold(threshold); } double maxStorageCapacity = (double)this.storageCapacity * threshold; - long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount); + + // Some names won't need/use much space, so give all names a 4x multiplier to compensate + long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount) * PER_NAME_STORAGE_MULTIPLIER; return maxStoragePerName; } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java index eec0d935..663bc22a 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java @@ -16,6 +16,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.Base58; +import org.qortal.utils.ListUtils; import org.qortal.utils.NTP; import org.qortal.utils.Triple; @@ -101,7 +102,14 @@ public class ArbitraryMetadataManager { if (metadataFile.exists()) { // Use local copy ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath()); - transactionMetadata.read(); + try { + transactionMetadata.read(); + } catch (DataException e) { + // Invalid file, so delete it + LOGGER.info("Deleting invalid metadata file due to exception: {}", e.getMessage()); + transactionMetadata.delete(); + return null; + } return transactionMetadata; } } @@ -332,7 +340,7 @@ public class ArbitraryMetadataManager { } // Check if the name is blocked - boolean isBlocked = (arbitraryTransactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(arbitraryTransactionData.getName())); + boolean isBlocked = (arbitraryTransactionData == null || ListUtils.isNameBlocked(arbitraryTransactionData.getName())); if (!isBlocked) { Peer requestingPeer = request.getB(); if (requestingPeer != null) { @@ -420,7 +428,7 @@ public class ArbitraryMetadataManager { } // We may need to forward this request on - boolean isBlocked = (transactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName())); + boolean isBlocked = (transactionData == null || ListUtils.isNameBlocked(transactionData.getName())); if (Settings.getInstance().isRelayModeEnabled() && !isBlocked) { // In relay mode - so ask our other peers if they have it diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index bd12f784..f06efdb8 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -39,9 +39,11 @@ public class AtStatesPruner implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); @@ -91,7 +93,8 @@ public class AtStatesPruner implements Runnable { if (upperPrunableHeight > upperBatchHeight) { pruneStartHeight = upperBatchHeight; repository.getATRepository().setAtPruneHeight(pruneStartHeight); - repository.getATRepository().rebuildLatestAtStates(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); final int finalPruneStartHeight = pruneStartHeight; diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 69fa347c..125628f1 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -26,9 +26,11 @@ public class AtStatesTrimmer implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); + int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); @@ -69,7 +71,8 @@ public class AtStatesTrimmer implements Runnable { if (upperTrimmableHeight > upperBatchHeight) { trimStartHeight = upperBatchHeight; repository.getATRepository().setAtTrimHeight(trimStartHeight); - repository.getATRepository().rebuildLatestAtStates(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); final int finalTrimStartHeight = trimStartHeight; diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java new file mode 100644 index 00000000..63579d3c --- /dev/null +++ b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java @@ -0,0 +1,121 @@ +package org.qortal.controller.repository; + +import org.apache.commons.io.FileUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.controller.Synchronizer; +import org.qortal.repository.*; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + + +public class BlockArchiveRebuilder { + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveRebuilder.class); + + private final int serializationVersion; + + public BlockArchiveRebuilder(int serializationVersion) { + this.serializationVersion = serializationVersion; + } + + public void start() throws DataException, IOException { + if (!Settings.getInstance().isArchiveEnabled() || Settings.getInstance().isLite()) { + return; + } + + // New archive path is in a different location from original archive path, to avoid conflicts. + // It will be moved later, once the process is complete. + final Path newArchivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive-rebuild"); + final Path originalArchivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive"); + + // Delete archive-rebuild if it exists from a previous attempt + FileUtils.deleteDirectory(newArchivePath.toFile()); + + try (final Repository repository = RepositoryManager.getRepository()) { + int startHeight = 1; // We need to rebuild the entire archive + + LOGGER.info("Rebuilding block archive from height {}...", startHeight); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(1000L); + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Synchronizer.getInstance().isSynchronizing()) { + continue; + } + + // Rebuild archive + try { + final int maximumArchiveHeight = BlockArchiveReader.getInstance().getHeightOfLastArchivedBlock(); + if (startHeight >= maximumArchiveHeight) { + // We've finished. + // Delete existing archive and move the newly built one into its place + FileUtils.deleteDirectory(originalArchivePath.toFile()); + FileUtils.moveDirectory(newArchivePath.toFile(), originalArchivePath.toFile()); + BlockArchiveReader.getInstance().invalidateFileListCache(); + LOGGER.info("Block archive successfully rebuilt"); + return; + } + + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, serializationVersion, newArchivePath, repository); + + // Set data source to BLOCK_ARCHIVE as we are rebuilding + writer.setDataSource(BlockArchiveWriter.BlockArchiveDataSource.BLOCK_ARCHIVE); + + // We can't enforce the 100MB file size target, as the final file needs to contain all blocks + // that exist in the current archive. Otherwise, the final blocks in the archive will be lost. + writer.setShouldEnforceFileSizeTarget(false); + + // We want to log the rebuild progress + writer.setShouldLogProgress(true); + + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.saveChanges(); + break; + + case STOPPING: + return; + + // We've reached the limit of the blocks we can archive + // Sleep for a while to allow more to become available + case NOT_ENOUGH_BLOCKS: + // This shouldn't happen, as we're not enforcing minimum file sizes + repository.discardChanges(); + throw new DataException("Unable to rebuild archive due to unexpected NOT_ENOUGH_BLOCKS response."); + + case BLOCK_NOT_FOUND: + // We tried to archive a block that didn't exist. This is a major failure and likely means + // that a bootstrap or re-sync is needed. Try again every minute until then. + LOGGER.info("Error: block not found when rebuilding archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + repository.discardChanges(); + throw new DataException("Unable to rebuild archive because a block is missing."); + } + + } catch (IOException | TransformationException e) { + LOGGER.info("Caught exception when rebuilding block archive", e); + throw new DataException("Unable to rebuild block archive"); + } + + } + } catch (InterruptedException e) { + // Do nothing + } finally { + // Delete archive-rebuild if it still exists, as that means something went wrong + FileUtils.deleteDirectory(newArchivePath.toFile()); + } + } + +} diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java index e69d1a35..698ad487 100644 --- a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java +++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java @@ -13,7 +13,9 @@ import org.qortal.repository.RepositoryManager; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.Unicode; +import java.math.BigInteger; import java.util.*; +import java.util.stream.Collectors; public class NamesDatabaseIntegrityCheck { @@ -23,21 +25,14 @@ public class NamesDatabaseIntegrityCheck { TransactionType.REGISTER_NAME, TransactionType.UPDATE_NAME, TransactionType.BUY_NAME, - TransactionType.SELL_NAME + TransactionType.SELL_NAME, + TransactionType.CANCEL_SELL_NAME ); private List nameTransactions = new ArrayList<>(); + public int rebuildName(String name, Repository repository) { - return this.rebuildName(name, repository, null); - } - - public int rebuildName(String name, Repository repository, List referenceNames) { - // "referenceNames" tracks the linked names that have already been rebuilt, to prevent circular dependencies - if (referenceNames == null) { - referenceNames = new ArrayList<>(); - } - int modificationCount = 0; try { List transactions = this.fetchAllTransactionsInvolvingName(name, repository); @@ -46,6 +41,14 @@ public class NamesDatabaseIntegrityCheck { return modificationCount; } + // If this name has been updated at any point, we need to add transactions from the other names to the sequence + int added = this.addAdditionalTransactionsRelatingToName(transactions, name, repository); + while (added > 0) { + // Keep going until all have been added + LOGGER.trace("{} added for {}. Looking for more transactions...", added, name); + added = this.addAdditionalTransactionsRelatingToName(transactions, name, repository); + } + // Loop through each past transaction and re-apply it to the Names table for (TransactionData currentTransaction : transactions) { @@ -61,29 +64,14 @@ public class NamesDatabaseIntegrityCheck { // Process UPDATE_NAME transactions if (currentTransaction.getType() == TransactionType.UPDATE_NAME) { UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) currentTransaction; - - if (Objects.equals(updateNameTransactionData.getNewName(), name) && - !Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) { - // This renames an existing name, so we need to process that instead - - if (!referenceNames.contains(name)) { - referenceNames.add(name); - this.rebuildName(updateNameTransactionData.getName(), repository, referenceNames); - } - else { - // We've already processed this name so there's nothing more to do - } - } - else { - Name nameObj = new Name(repository, name); - if (nameObj != null && nameObj.getNameData() != null) { - nameObj.update(updateNameTransactionData); - modificationCount++; - LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name); - } else { - // Something went wrong - throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName())); - } + Name nameObj = new Name(repository, updateNameTransactionData.getName()); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.update(updateNameTransactionData); + modificationCount++; + LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name); + } else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName())); } } @@ -102,6 +90,21 @@ public class NamesDatabaseIntegrityCheck { } } + // Process CANCEL_SELL_NAME transactions + if (currentTransaction.getType() == TransactionType.CANCEL_SELL_NAME) { + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) currentTransaction; + Name nameObj = new Name(repository, cancelSellNameTransactionData.getName()); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.cancelSell(cancelSellNameTransactionData); + modificationCount++; + LOGGER.trace("Processed CANCEL_SELL_NAME transaction for name {}", name); + } + else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", cancelSellNameTransactionData.getName())); + } + } + // Process BUY_NAME transactions if (currentTransaction.getType() == TransactionType.BUY_NAME) { BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) currentTransaction; @@ -128,7 +131,7 @@ public class NamesDatabaseIntegrityCheck { public int rebuildAllNames() { int modificationCount = 0; try (final Repository repository = RepositoryManager.getRepository()) { - List names = this.fetchAllNames(repository); + List names = this.fetchAllNames(repository); // TODO: de-duplicate, to speed up this process for (String name : names) { modificationCount += this.rebuildName(name, repository); } @@ -326,6 +329,10 @@ public class NamesDatabaseIntegrityCheck { TransactionType.BUY_NAME, Arrays.asList("name = ?"), Arrays.asList(name)); signatures.addAll(buyNameTransactions); + List cancelSellNameTransactions = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( + TransactionType.CANCEL_SELL_NAME, Arrays.asList("name = ?"), Arrays.asList(name)); + signatures.addAll(cancelSellNameTransactions); + List transactions = new ArrayList<>(); for (byte[] signature : signatures) { TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); @@ -335,8 +342,8 @@ public class NamesDatabaseIntegrityCheck { } } - // Sort by lowest timestamp first - transactions.sort(Comparator.comparingLong(TransactionData::getTimestamp)); + // Sort by lowest block height first + sortTransactions(transactions); return transactions; } @@ -390,8 +397,77 @@ public class NamesDatabaseIntegrityCheck { names.add(sellNameTransactionData.getName()); } } + if ((transactionData instanceof CancelSellNameTransactionData)) { + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) transactionData; + if (!names.contains(cancelSellNameTransactionData.getName())) { + names.add(cancelSellNameTransactionData.getName()); + } + } } return names; } + private int addAdditionalTransactionsRelatingToName(List transactions, String name, Repository repository) throws DataException { + int added = 0; + + // If this name has been updated at any point, we need to add transactions from the other names to the sequence + List otherNames = new ArrayList<>(); + List updateNameTransactions = transactions.stream().filter(t -> t.getType() == TransactionType.UPDATE_NAME).collect(Collectors.toList()); + for (TransactionData transactionData : updateNameTransactions) { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; + // If the newName field isn't empty, and either the "name" or "newName" is different from our reference name, + // we should remember this additional name, in case it has relevant transactions associated with it. + if (updateNameTransactionData.getNewName() != null && !updateNameTransactionData.getNewName().isEmpty()) { + if (!Objects.equals(updateNameTransactionData.getName(), name)) { + otherNames.add(updateNameTransactionData.getName()); + } + if (!Objects.equals(updateNameTransactionData.getNewName(), name)) { + otherNames.add(updateNameTransactionData.getNewName()); + } + } + } + + + for (String otherName : otherNames) { + List otherNameTransactions = this.fetchAllTransactionsInvolvingName(otherName, repository); + for (TransactionData otherNameTransactionData : otherNameTransactions) { + if (!transactions.contains(otherNameTransactionData)) { + // Add new transaction relating to other name + transactions.add(otherNameTransactionData); + added++; + } + } + } + + if (added > 0) { + // New transaction(s) added, so re-sort + sortTransactions(transactions); + } + + return added; + } + + private void sortTransactions(List transactions) { + Collections.sort(transactions, new Comparator() { + public int compare(Object o1, Object o2) { + TransactionData td1 = (TransactionData) o1; + TransactionData td2 = (TransactionData) o2; + + // Sort by block height first + int heightComparison = td1.getBlockHeight().compareTo(td2.getBlockHeight()); + if (heightComparison != 0) { + return heightComparison; + } + + // Same height so compare timestamps + int timestampComparison = Long.compare(td1.getTimestamp(), td2.getTimestamp()); + if (timestampComparison != 0) { + return timestampComparison; + } + + // Same timestamp so compare signatures + return new BigInteger(td1.getSignature()).compareTo(new BigInteger(td2.getSignature())); + }}); + } + } diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index ec27456f..dfb6290b 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -157,4 +157,18 @@ public class PruneManager { return (height < latestUnprunedHeight); } + /** + * When rebuilding the latest AT states, we need to specify a maxHeight, so that we aren't tracking + * very recent AT states that could potentially be orphaned. This method ensures that AT states + * are given a sufficient number of blocks to confirm before being tracked as a latest AT state. + */ + public static int getMaxHeightForLatestAtStates(Repository repository) throws DataException { + // Get current chain height, and subtract a certain number of "confirmation" blocks + // This is to ensure we are basing our latest AT states data on confirmed blocks - + // ones that won't be orphaned in any normal circumstances + final int confirmationBlocks = 250; + final int chainHeight = repository.getBlockRepository().getBlockchainHeight(); + return chainHeight - confirmationBlocks; + } + } diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java index 9033e717..9ab97be9 100644 --- a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java index 171e818b..4b1ba7bb 100644 --- a/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java index d37a6650..52e7bb24 100644 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java index 996097f3..b57b9354 100644 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java index a31a1a28..b5631f0b 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java index 9834df20..4b5126d9 100644 --- a/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java @@ -20,6 +20,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -320,20 +321,36 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddressT3)); @@ -561,15 +578,25 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -681,15 +708,25 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java index 80fe7932..ed71d0e3 100644 --- a/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 5880f561..147554dd 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.Logger; import org.bitcoinj.core.ECKey; import org.qortal.account.PrivateKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.TransactionsResource; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult; @@ -19,6 +20,7 @@ import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.network.TradePresenceData; +import org.qortal.data.transaction.TransactionData; import org.qortal.event.Event; import org.qortal.event.EventBus; import org.qortal.event.Listener; @@ -33,6 +35,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.repository.hsqldb.HSQLDBImportExport; import org.qortal.settings.Settings; +import org.qortal.transaction.Transaction; import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; @@ -113,6 +116,9 @@ public class TradeBot implements Listener { private Map safeAllTradePresencesByPubkey = Collections.emptyMap(); private long nextTradePresenceBroadcastTimestamp = 0L; + private Map failedTrades = new HashMap<>(); + private Map validTrades = new HashMap<>(); + private TradeBot() { EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); } @@ -327,7 +333,7 @@ public class TradeBot implements Listener { SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO); if (logMessageSupplier != null) - LOGGER.info(logMessageSupplier); + LOGGER.info(logMessageSupplier.get()); LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState)); @@ -674,6 +680,78 @@ public class TradeBot implements Listener { }); } + /** Removes any trades that have had multiple failures */ + public List removeFailedTrades(Repository repository, List crossChainTrades) { + Long now = NTP.getTime(); + if (now == null) { + return crossChainTrades; + } + + List updatedCrossChainTrades = new ArrayList<>(crossChainTrades); + int getMaxTradeOfferAttempts = Settings.getInstance().getMaxTradeOfferAttempts(); + + for (CrossChainTradeData crossChainTradeData : crossChainTrades) { + // We only care about trades in the OFFERING state + if (crossChainTradeData.mode != AcctMode.OFFERING) { + failedTrades.remove(crossChainTradeData.qortalAtAddress); + validTrades.remove(crossChainTradeData.qortalAtAddress); + continue; + } + + // Return recently cached values if they exist + Long failedTimestamp = failedTrades.get(crossChainTradeData.qortalAtAddress); + if (failedTimestamp != null && now - failedTimestamp < 60 * 60 * 1000L) { + updatedCrossChainTrades.remove(crossChainTradeData); + //LOGGER.info("Removing cached failed trade AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + Long validTimestamp = validTrades.get(crossChainTradeData.qortalAtAddress); + if (validTimestamp != null && now - validTimestamp < 60 * 60 * 1000L) { + //LOGGER.info("NOT removing cached valid trade AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + + try { + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.CONFIRMED, null, null, null); + if (signatures.size() < getMaxTradeOfferAttempts) { + // Less than 3 (or user-specified number of) MESSAGE transactions relate to this trade, so assume it is ok + validTrades.put(crossChainTradeData.qortalAtAddress, now); + continue; + } + + List transactions = new ArrayList<>(signatures.size()); + for (byte[] signature : signatures) { + transactions.add(repository.getTransactionRepository().fromSignature(signature)); + } + transactions.sort(Transaction.getDataComparator()); + + // Get timestamp of the first MESSAGE transaction + long firstMessageTimestamp = transactions.get(0).getTimestamp(); + + // Treat as failed if first buy attempt was more than 60 mins ago (as it's still in the OFFERING state) + boolean isFailed = (now - firstMessageTimestamp > 60*60*1000L); + if (isFailed) { + failedTrades.put(crossChainTradeData.qortalAtAddress, now); + updatedCrossChainTrades.remove(crossChainTradeData); + } + else { + validTrades.put(crossChainTradeData.qortalAtAddress, now); + } + + } catch (DataException e) { + LOGGER.info("Unable to determine failed state of AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + } + + return updatedCrossChainTrades; + } + + public boolean isFailedTrade(Repository repository, CrossChainTradeData crossChainTradeData) { + List results = removeFailedTrades(repository, Arrays.asList(crossChainTradeData)); + return results.isEmpty(); + } + private long generateExpiry(long timestamp) { return ((timestamp - 1) / EXPIRY_ROUNDING) * EXPIRY_ROUNDING + PRESENCE_LIFETIME; } diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java index 7fec5a17..b65bac8e 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoin.java +++ b/src/main/java/org/qortal/crosschain/Bitcoin.java @@ -49,6 +49,7 @@ public class Bitcoin extends Bitcoiny { //CLOSED new Server("bitcoin.grey.pw", Server.ConnectionType.SSL, 50002), //CLOSED new Server("btc.litepay.ch", Server.ConnectionType.SSL, 50002), //CLOSED new Server("electrum.pabu.io", Server.ConnectionType.SSL, 50002), + //CLOSED new Server("electrumx.dev", Server.ConnectionType.SSL, 50002), //CLOSED new Server("electrumx.hodlwallet.com", Server.ConnectionType.SSL, 50002), //CLOSED new Server("gd42.org", Server.ConnectionType.SSL, 50002), //CLOSED new Server("korea.electrum-server.com", Server.ConnectionType.SSL, 50002), @@ -56,28 +57,75 @@ public class Bitcoin extends Bitcoiny { //1.15.0 new Server("alviss.coinjoined.com", Server.ConnectionType.SSL, 50002), //1.15.0 new Server("electrum.acinq.co", Server.ConnectionType.SSL, 50002), //1.14.0 new Server("electrum.coinext.com.br", Server.ConnectionType.SSL, 50002), + //F1.7.0 new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002), new Server("104.248.139.211", Server.ConnectionType.SSL, 50002), + new Server("128.0.190.26", Server.ConnectionType.SSL, 50002), new Server("142.93.6.38", Server.ConnectionType.SSL, 50002), new Server("157.245.172.236", Server.ConnectionType.SSL, 50002), new Server("167.172.226.175", Server.ConnectionType.SSL, 50002), new Server("167.172.42.31", Server.ConnectionType.SSL, 50002), new Server("178.62.80.20", Server.ConnectionType.SSL, 50002), new Server("185.64.116.15", Server.ConnectionType.SSL, 50002), + new Server("188.165.206.215", Server.ConnectionType.SSL, 50002), + new Server("188.165.211.112", Server.ConnectionType.SSL, 50002), + new Server("2azzarita.hopto.org", Server.ConnectionType.SSL, 50002), + new Server("2electrumx.hopto.me", Server.ConnectionType.SSL, 56022), + new Server("2ex.digitaleveryware.com", Server.ConnectionType.SSL, 50002), + new Server("65.39.140.37", Server.ConnectionType.SSL, 50002), new Server("68.183.188.105", Server.ConnectionType.SSL, 50002), + new Server("71.73.14.254", Server.ConnectionType.SSL, 50002), + new Server("94.23.247.135", Server.ConnectionType.SSL, 50002), + new Server("assuredly.not.fyi", Server.ConnectionType.SSL, 50002), + new Server("ax101.blockeng.ch", Server.ConnectionType.SSL, 50002), + new Server("ax102.blockeng.ch", Server.ConnectionType.SSL, 50002), + new Server("b.1209k.com", Server.ConnectionType.SSL, 50002), + new Server("b6.1209k.com", Server.ConnectionType.SSL, 50002), + new Server("bitcoin.dermichi.com", Server.ConnectionType.SSL, 50002), + new Server("bitcoin.lu.ke", Server.ConnectionType.SSL, 50002), new Server("bitcoin.lukechilds.co", Server.ConnectionType.SSL, 50002), new Server("blkhub.net", Server.ConnectionType.SSL, 50002), - new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002), + new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002), + new Server("btc.ocf.sh", Server.ConnectionType.SSL, 50002), new Server("btce.iiiiiii.biz", Server.ConnectionType.SSL, 50002), new Server("caleb.vegas", Server.ConnectionType.SSL, 50002), new Server("eai.coincited.net", Server.ConnectionType.SSL, 50002), + new Server("electrum.bhoovd.com", Server.ConnectionType.SSL, 50002), new Server("electrum.bitaroo.net", Server.ConnectionType.SSL, 50002), - new Server("electrumx.dev", Server.ConnectionType.SSL, 50002), + new Server("electrum.bitcoinlizard.net", Server.ConnectionType.SSL, 50002), + new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 50002), + new Server("electrum.emzy.de", Server.ConnectionType.SSL, 50002), + new Server("electrum.exan.tech", Server.ConnectionType.SSL, 50002), + new Server("electrum.kendigisland.xyz", Server.ConnectionType.SSL, 50002), + new Server("electrum.mmitech.info", Server.ConnectionType.SSL, 50002), + new Server("electrum.petrkr.net", Server.ConnectionType.SSL, 50002), + new Server("electrum.stippy.com", Server.ConnectionType.SSL, 50002), + new Server("electrum.thomasfischbach.de", Server.ConnectionType.SSL, 50002), + new Server("electrum0.snel.it", Server.ConnectionType.SSL, 50002), + new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 50002), + new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 50002), + new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 50002), + new Server("electrumx.alexridevski.net", Server.ConnectionType.SSL, 50002), + new Server("electrumx-core.1209k.com", Server.ConnectionType.SSL, 50002), new Server("elx.bitske.com", Server.ConnectionType.SSL, 50002), + new Server("ex03.axalgo.com", Server.ConnectionType.SSL, 50002), + new Server("ex05.axalgo.com", Server.ConnectionType.SSL, 50002), + new Server("ex07.axalgo.com", Server.ConnectionType.SSL, 50002), new Server("fortress.qtornado.com", Server.ConnectionType.SSL, 50002), + new Server("fulcrum.grey.pw", Server.ConnectionType.SSL, 50002), + new Server("fulcrum.sethforprivacy.com", Server.ConnectionType.SSL, 51002), new Server("guichet.centure.cc", Server.ConnectionType.SSL, 50002), - new Server("kareoke.qoppa.org", Server.ConnectionType.SSL, 50002), new Server("hodlers.beer", Server.ConnectionType.SSL, 50002), + new Server("kareoke.qoppa.org", Server.ConnectionType.SSL, 50002), + new Server("kirsche.emzy.de", Server.ConnectionType.SSL, 50002), new Server("node1.btccuracao.com", Server.ConnectionType.SSL, 50002), + new Server("osr1ex1.compumundohipermegared.one", Server.ConnectionType.SSL, 50002), + new Server("smmalis37.ddns.net", Server.ConnectionType.SSL, 50002), + new Server("ulrichard.ch", Server.ConnectionType.SSL, 50002), + new Server("vmd104012.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd104014.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd63185.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd71287.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd84592.contaboserver.net", Server.ConnectionType.SSL, 50002), new Server("xtrum.com", Server.ConnectionType.SSL, 50002)); } diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index 350779bc..d1523b50 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -167,6 +167,16 @@ public abstract class Bitcoiny implements ForeignBlockchain { return blockTimestamps.get(5); } + /** + * Returns height from latest block. + *

+ * @throws ForeignBlockchainException if error occurs + */ + public int getBlockchainHeight() throws ForeignBlockchainException { + int height = this.blockchainProvider.getCurrentHeight(); + return height; + } + /** Returns fee per transaction KB. To be overridden for testnet/regtest. */ public Coin getFeePerKb() { return this.bitcoinjContext.getFeePerKb(); @@ -357,19 +367,33 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @return unspent BTC balance, or null if unable to determine balance */ public Long getWalletBalance(String key58) throws ForeignBlockchainException { - // It's more accurate to calculate the balance from the transactions, rather than asking Bitcoinj - return this.getWalletBalanceFromTransactions(key58); + Long balance = 0L; -// Context.propagate(bitcoinjContext); -// -// Wallet wallet = walletFromDeterministicKey58(key58); -// wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); -// -// Coin balance = wallet.getBalance(); -// if (balance == null) -// return null; -// -// return balance.value; + List allUnspentOutputs = new ArrayList<>(); + Set walletAddresses = this.getWalletAddresses(key58); + for (String address : walletAddresses) { + allUnspentOutputs.addAll(this.getUnspentOutputs(address)); + } + for (TransactionOutput output : allUnspentOutputs) { + if (!output.isAvailableForSpending()) { + continue; + } + balance += output.getValue().value; + } + return balance; + } + + public Long getWalletBalanceFromBitcoinj(String key58) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); + + Coin balance = wallet.getBalance(); + if (balance == null) + return null; + + return balance.value; } public Long getWalletBalanceFromTransactions(String key58) throws ForeignBlockchainException { @@ -464,6 +488,64 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + public Set getWalletAddresses(String key58) throws ForeignBlockchainException { + synchronized (this) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + Set keySet = new HashSet<>(); + + int unusedCounter = 0; + int ki = 0; + do { + boolean areAllKeysUnused = true; + + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + + // Check for transactions + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + keySet.add(address.toString()); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = this.getAddressTransactions(script, false); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + } + } + + if (areAllKeysUnused) { + // No transactions + if (unusedCounter >= Settings.getInstance().getGapLimit()) { + // ... and we've hit our search limit + break; + } + // We haven't hit our search limit yet so increment the counter and keep looking + unusedCounter += WALLET_KEY_LOOKAHEAD_INCREMENT; + } else { + // Some keys in this batch were used, so reset the counter + unusedCounter = 0; + } + + // Generate some more keys + keys.addAll(generateMoreKeys(keyChain)); + + // Process new keys + } while (true); + + return keySet; + } + } + protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set keySet) { long amount = 0; long total = 0L; diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java index 3ab5e78e..c5d96383 100644 --- a/src/main/java/org/qortal/crosschain/Digibyte.java +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -45,6 +45,9 @@ public class Digibyte extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=dgb + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 55002), + new Server("electrum-dgb.qortal.online", ConnectionType.SSL, 50002), + new Server("electrum1-dgb.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20059), new Server("electrum2.cipig.net", ConnectionType.SSL, 20059), new Server("electrum3.cipig.net", ConnectionType.SSL, 20059)); @@ -134,6 +137,8 @@ public class Digibyte extends Bitcoiny { Context bitcoinjContext = new Context(digibyteNet.getParams()); instance = new Digibyte(digibyteNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index d6955e18..99f557a5 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -45,10 +45,13 @@ public class Dogecoin extends Bitcoiny { public Collection getServers() { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! + // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=doge + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 54002), + new Server("electrum-doge.qortal.online", ConnectionType.SSL, 50002), + new Server("electrum1-doge.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20060), new Server("electrum2.cipig.net", ConnectionType.SSL, 20060), new Server("electrum3.cipig.net", ConnectionType.SSL, 20060)); - // TODO: add more mainnet servers. It's too centralized. } @Override diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index a2a42089..a331b111 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -5,6 +5,7 @@ import java.math.BigDecimal; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; +import java.text.DecimalFormat; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,7 +31,11 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); private static final Random RANDOM = new Random(); + // See: https://electrumx.readthedocs.io/en/latest/protocol-changes.html private static final double MIN_PROTOCOL_VERSION = 1.2; + private static final double MAX_PROTOCOL_VERSION = 2.0; // Higher than current latest, for hopeful future-proofing + private static final String CLIENT_NAME = "Qortal"; + private static final int BLOCK_HEADER_LENGTH = 80; // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" @@ -40,7 +45,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported"; private static final int RESPONSE_TIME_READINGS = 5; - private static final long MAX_AVG_RESPONSE_TIME = 500L; // ms + private static final long MAX_AVG_RESPONSE_TIME = 1000L; // ms public static class Server { String hostname; @@ -679,6 +684,9 @@ public class ElectrumX extends BitcoinyBlockchainProvider { this.scanner = new Scanner(this.socket.getInputStream()); this.scanner.useDelimiter("\n"); + // All connections need to start with a version negotiation + this.connectedRpc("server.version"); + // Check connection is suitable by asking for server features, including genesis block hash JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features"); @@ -725,6 +733,17 @@ public class ElectrumX extends BitcoinyBlockchainProvider { JSONArray requestParams = new JSONArray(); requestParams.addAll(Arrays.asList(params)); + + // server.version needs additional params to negotiate a version + if (method.equals("server.version")) { + requestParams.add(CLIENT_NAME); + List versions = new ArrayList<>(); + DecimalFormat df = new DecimalFormat("#.#"); + versions.add(df.format(MIN_PROTOCOL_VERSION)); + versions.add(df.format(MAX_PROTOCOL_VERSION)); + requestParams.add(versions); + } + requestJson.put("params", requestParams); String request = requestJson.toJSONString() + "\n"; diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 3ab30b2b..1dd9037a 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -45,15 +45,19 @@ public class Litecoin extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=ltc - //CLOSED new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), //CLOSED new Server("electrum-ltc.someguy123.net", Server.ConnectionType.SSL, 50002), + //CLOSED new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022), + //BEHIND new Server("62.171.169.176", Server.ConnectionType.SSL, 50002), //PHISHY new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 50002), new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443), + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 50002), new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002), + new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), + new Server("electrum-ltc.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum1-ltc.qortal.online", Server.ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20063), - new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022), new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002)); } diff --git a/src/main/java/org/qortal/crosschain/PirateChain.java b/src/main/java/org/qortal/crosschain/PirateChain.java index 09b37481..a1d31a4e 100644 --- a/src/main/java/org/qortal/crosschain/PirateChain.java +++ b/src/main/java/org/qortal/crosschain/PirateChain.java @@ -57,9 +57,9 @@ public class PirateChain extends Bitcoiny { public Collection getServers() { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! - new Server("arrrlightd.qortal.online", ConnectionType.SSL, 443), - new Server("arrrlightd1.qortal.online", ConnectionType.SSL, 443), - new Server("arrrlightd2.qortal.online", ConnectionType.SSL, 443), + new Server("wallet-arrr1.qortal.online", ConnectionType.SSL, 443), + new Server("wallet-arrr2.qortal.online", ConnectionType.SSL, 443), + new Server("wallet-arrr3.qortal.online", ConnectionType.SSL, 443), new Server("lightd.pirate.black", ConnectionType.SSL, 443)); } diff --git a/src/main/java/org/qortal/crosschain/PirateWallet.java b/src/main/java/org/qortal/crosschain/PirateWallet.java index 6c6ed2a9..4b95d3cc 100644 --- a/src/main/java/org/qortal/crosschain/PirateWallet.java +++ b/src/main/java/org/qortal/crosschain/PirateWallet.java @@ -117,7 +117,7 @@ public class PirateWallet { // Restore existing wallet String response = LiteWalletJni.initfromb64(serverUri, params, wallet, saplingOutput64, saplingSpend64); if (response != null && !response.contains("\"initalized\":true")) { - LOGGER.info("Unable to initialize Pirate Chain wallet: {}", response); + LOGGER.info("Unable to initialize Pirate Chain wallet at {}: {}", serverUri, response); return false; } this.seedPhrase = inputSeedPhrase; diff --git a/src/main/java/org/qortal/crosschain/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java index d65c0a13..6030fa50 100644 --- a/src/main/java/org/qortal/crosschain/Ravencoin.java +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -45,13 +45,17 @@ public class Ravencoin extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=rvn - new Server("aethyn.com", ConnectionType.SSL, 50002), - new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002), - new Server("rvn-dashboard.com", ConnectionType.SSL, 50002), - new Server("rvn4lyfe.com", ConnectionType.SSL, 50002), + //CLOSED new Server("aethyn.com", ConnectionType.SSL, 50002), + //CLOSED new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002), + //BEHIND new Server("electrum3.rvn.rocks", ConnectionType.SSL, 50002), + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 56002), + new Server("electrum-rvn.qortal.online", ConnectionType.SSL, 50002), + new Server("electrum1-rvn.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20051), new Server("electrum2.cipig.net", ConnectionType.SSL, 20051), - new Server("electrum3.cipig.net", ConnectionType.SSL, 20051)); + new Server("electrum3.cipig.net", ConnectionType.SSL, 20051), + new Server("rvn-dashboard.com", ConnectionType.SSL, 50002), + new Server("rvn4lyfe.com", ConnectionType.SSL, 50002)); } @Override @@ -138,6 +142,8 @@ public class Ravencoin extends Bitcoiny { Context bitcoinjContext = new Context(ravencoinNet.getParams()); instance = new Ravencoin(ravencoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; diff --git a/src/main/java/org/qortal/crypto/AES.java b/src/main/java/org/qortal/crypto/AES.java index 0e8018f5..1286fb81 100644 --- a/src/main/java/org/qortal/crypto/AES.java +++ b/src/main/java/org/qortal/crypto/AES.java @@ -202,4 +202,12 @@ public class AES { .decode(cipherText))); } + public static long getEncryptedFileSize(long inFileSize) { + // To calculate the resulting file size, add 16 (for the IV), then round up to the nearest multiple of 16 + final int ivSize = 16; + final int chunkSize = 16; + final int expectedSize = Math.round((inFileSize + ivSize) / chunkSize) * chunkSize + chunkSize; + return expectedSize; + } + } diff --git a/src/main/java/org/qortal/crypto/MemoryPoW.java b/src/main/java/org/qortal/crypto/MemoryPoW.java index f27c8f7a..634b8f9b 100644 --- a/src/main/java/org/qortal/crypto/MemoryPoW.java +++ b/src/main/java/org/qortal/crypto/MemoryPoW.java @@ -99,6 +99,10 @@ public class MemoryPoW { } public static boolean verify2(byte[] data, int workBufferLength, long difficulty, int nonce) { + return verify2(data, null, workBufferLength, difficulty, nonce); + } + + public static boolean verify2(byte[] data, long[] workBuffer, int workBufferLength, long difficulty, int nonce) { // Hash data with SHA256 byte[] hash = Crypto.digest(data); @@ -111,7 +115,10 @@ public class MemoryPoW { byteBuffer = null; int longBufferLength = workBufferLength / 8; - long[] workBuffer = new long[longBufferLength]; + + if (workBuffer == null) + workBuffer = new long[longBufferLength]; + long[] state = new long[4]; long seed = 8682522807148012L; diff --git a/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java b/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java index aba1955e..f723e651 100644 --- a/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java +++ b/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java @@ -28,7 +28,7 @@ public abstract class TrustlessSSLSocketFactory { private static final SSLContext sc; static { try { - sc = SSLContext.getInstance("SSL"); + sc = SSLContext.getInstance("TLSv1.3"); sc.init(null, TRUSTLESS_MANAGER, new java.security.SecureRandom()); } catch (Exception e) { throw new RuntimeException(e); diff --git a/src/main/java/org/qortal/data/account/AccountData.java b/src/main/java/org/qortal/data/account/AccountData.java index 4d662f04..868d1bc1 100644 --- a/src/main/java/org/qortal/data/account/AccountData.java +++ b/src/main/java/org/qortal/data/account/AccountData.java @@ -18,6 +18,7 @@ public class AccountData { protected int level; protected int blocksMinted; protected int blocksMintedAdjustment; + protected int blocksMintedPenalty; // Constructors @@ -25,7 +26,7 @@ public class AccountData { protected AccountData() { } - public AccountData(String address, byte[] reference, byte[] publicKey, int defaultGroupId, int flags, int level, int blocksMinted, int blocksMintedAdjustment) { + public AccountData(String address, byte[] reference, byte[] publicKey, int defaultGroupId, int flags, int level, int blocksMinted, int blocksMintedAdjustment, int blocksMintedPenalty) { this.address = address; this.reference = reference; this.publicKey = publicKey; @@ -34,10 +35,11 @@ public class AccountData { this.level = level; this.blocksMinted = blocksMinted; this.blocksMintedAdjustment = blocksMintedAdjustment; + this.blocksMintedPenalty = blocksMintedPenalty; } public AccountData(String address) { - this(address, null, null, Group.NO_GROUP, 0, 0, 0, 0); + this(address, null, null, Group.NO_GROUP, 0, 0, 0, 0, 0); } // Getters/Setters @@ -102,6 +104,14 @@ public class AccountData { this.blocksMintedAdjustment = blocksMintedAdjustment; } + public int getBlocksMintedPenalty() { + return this.blocksMintedPenalty; + } + + public void setBlocksMintedPenalty(int blocksMintedPenalty) { + this.blocksMintedPenalty = blocksMintedPenalty; + } + // Comparison @Override diff --git a/src/main/java/org/qortal/data/account/AccountPenaltyData.java b/src/main/java/org/qortal/data/account/AccountPenaltyData.java new file mode 100644 index 00000000..61947a5f --- /dev/null +++ b/src/main/java/org/qortal/data/account/AccountPenaltyData.java @@ -0,0 +1,52 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class AccountPenaltyData { + + // Properties + private String address; + private int blocksMintedPenalty; + + // Constructors + + // necessary for JAXB + protected AccountPenaltyData() { + } + + public AccountPenaltyData(String address, int blocksMintedPenalty) { + this.address = address; + this.blocksMintedPenalty = blocksMintedPenalty; + } + + // Getters/Setters + + public String getAddress() { + return this.address; + } + + public int getBlocksMintedPenalty() { + return this.blocksMintedPenalty; + } + + public String toString() { + return String.format("%s has penalty %d", this.address, this.blocksMintedPenalty); + } + + @Override + public boolean equals(Object b) { + if (!(b instanceof AccountPenaltyData)) + return false; + + return this.getAddress().equals(((AccountPenaltyData) b).getAddress()); + } + + @Override + public int hashCode() { + return address.hashCode(); + } + +} diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java index 135065aa..a09fc5ff 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java @@ -16,10 +16,17 @@ public class ArbitraryResourceInfo { public ArbitraryResourceMetadata metadata; public Long size; + public Long created; + public Long updated; public ArbitraryResourceInfo() { } + @Override + public String toString() { + return String.format("%s %s %s", name, service, identifier); + } + @Override public boolean equals(Object o) { if (o == this) diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java index e2bcaf56..a6aa6e26 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java @@ -15,22 +15,26 @@ public class ArbitraryResourceMetadata { private List tags; private Category category; private String categoryName; + private List files; + private String mimeType; public ArbitraryResourceMetadata() { } - public ArbitraryResourceMetadata(String title, String description, List tags, Category category) { + public ArbitraryResourceMetadata(String title, String description, List tags, Category category, List files, String mimeType) { this.title = title; this.description = description; this.tags = tags; this.category = category; + this.files = files; + this.mimeType = mimeType; if (category != null) { this.categoryName = category.getName(); } } - public static ArbitraryResourceMetadata fromTransactionMetadata(ArbitraryDataTransactionMetadata transactionMetadata) { + public static ArbitraryResourceMetadata fromTransactionMetadata(ArbitraryDataTransactionMetadata transactionMetadata, boolean includeFileList) { if (transactionMetadata == null) { return null; } @@ -38,11 +42,22 @@ public class ArbitraryResourceMetadata { String description = transactionMetadata.getDescription(); List tags = transactionMetadata.getTags(); Category category = transactionMetadata.getCategory(); + String mimeType = transactionMetadata.getMimeType(); - if (title == null && description == null && tags == null && category == null) { + // We don't always want to include the file list as it can be too verbose + List files = null; + if (includeFileList) { + files = transactionMetadata.getFiles(); + } + + if (title == null && description == null && tags == null && category == null && files == null && mimeType == null) { return null; } - return new ArbitraryResourceMetadata(title, description, tags, category); + return new ArbitraryResourceMetadata(title, description, tags, category, files, mimeType); + } + + public List getFiles() { + return this.files; } } diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java index b1fbbd3c..54dd2af6 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java @@ -8,6 +8,7 @@ public class ArbitraryResourceStatus { public enum Status { PUBLISHED("Published", "Published but not yet downloaded"), + NOT_PUBLISHED("Not published", "Resource does not exist"), DOWNLOADING("Downloading", "Locating and downloading files..."), DOWNLOADED("Downloaded", "Files downloaded"), BUILDING("Building", "Building..."), @@ -33,6 +34,7 @@ public class ArbitraryResourceStatus { private Integer localChunkCount; private Integer totalChunkCount; + private Float percentLoaded; public ArbitraryResourceStatus() { } @@ -44,6 +46,7 @@ public class ArbitraryResourceStatus { this.description = status.description; this.localChunkCount = localChunkCount; this.totalChunkCount = totalChunkCount; + this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null && this.totalChunkCount > 0) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null; } public ArbitraryResourceStatus(Status status) { diff --git a/src/main/java/org/qortal/data/chat/ActiveChats.java b/src/main/java/org/qortal/data/chat/ActiveChats.java index c546d637..248af82e 100644 --- a/src/main/java/org/qortal/data/chat/ActiveChats.java +++ b/src/main/java/org/qortal/data/chat/ActiveChats.java @@ -1,10 +1,15 @@ package org.qortal.data.chat; +import org.bouncycastle.util.encoders.Base64; +import org.qortal.utils.Base58; + import java.util.List; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import static org.qortal.data.chat.ChatMessage.Encoding; + @XmlAccessorType(XmlAccessType.FIELD) public class ActiveChats { @@ -17,17 +22,39 @@ public class ActiveChats { private Long timestamp; private String sender; private String senderName; + private byte[] signature; + private Encoding encoding; + private String data; protected GroupChat() { /* JAXB */ } - public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName) { + public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName, + byte[] signature, Encoding encoding, byte[] data) { this.groupId = groupId; this.groupName = groupName; this.timestamp = timestamp; this.sender = sender; this.senderName = senderName; + this.signature = signature; + this.encoding = encoding != null ? encoding : Encoding.BASE58; + + if (data != null) { + switch (this.encoding) { + case BASE64: + this.data = Base64.toBase64String(data); + break; + + case BASE58: + default: + this.data = Base58.encode(data); + break; + } + } + else { + this.data = null; + } } public int getGroupId() { @@ -49,6 +76,14 @@ public class ActiveChats { public String getSenderName() { return this.senderName; } + + public byte[] getSignature() { + return this.signature; + } + + public String getData() { + return this.data; + } } @XmlAccessorType(XmlAccessType.FIELD) @@ -118,4 +153,4 @@ public class ActiveChats { return this.direct; } -} +} \ No newline at end of file diff --git a/src/main/java/org/qortal/data/chat/ChatMessage.java b/src/main/java/org/qortal/data/chat/ChatMessage.java index 26df1da4..5d9ecb4e 100644 --- a/src/main/java/org/qortal/data/chat/ChatMessage.java +++ b/src/main/java/org/qortal/data/chat/ChatMessage.java @@ -1,11 +1,19 @@ package org.qortal.data.chat; +import org.bouncycastle.util.encoders.Base64; +import org.qortal.utils.Base58; + import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @XmlAccessorType(XmlAccessType.FIELD) public class ChatMessage { + public enum Encoding { + BASE58, + BASE64 + } + // Properties private long timestamp; @@ -27,7 +35,11 @@ public class ChatMessage { private String recipientName; - private byte[] data; + private byte[] chatReference; + + private Encoding encoding; + + private String data; private boolean isText; private boolean isEncrypted; @@ -42,8 +54,8 @@ public class ChatMessage { // For repository use public ChatMessage(long timestamp, int txGroupId, byte[] reference, byte[] senderPublicKey, String sender, - String senderName, String recipient, String recipientName, byte[] data, boolean isText, - boolean isEncrypted, byte[] signature) { + String senderName, String recipient, String recipientName, byte[] chatReference, + Encoding encoding, byte[] data, boolean isText, boolean isEncrypted, byte[] signature) { this.timestamp = timestamp; this.txGroupId = txGroupId; this.reference = reference; @@ -52,7 +64,25 @@ public class ChatMessage { this.senderName = senderName; this.recipient = recipient; this.recipientName = recipientName; - this.data = data; + this.chatReference = chatReference; + this.encoding = encoding != null ? encoding : Encoding.BASE58; + + if (data != null) { + switch (this.encoding) { + case BASE64: + this.data = Base64.toBase64String(data); + break; + + case BASE58: + default: + this.data = Base58.encode(data); + break; + } + } + else { + this.data = null; + } + this.isText = isText; this.isEncrypted = isEncrypted; this.signature = signature; @@ -90,7 +120,11 @@ public class ChatMessage { return this.recipientName; } - public byte[] getData() { + public byte[] getChatReference() { + return this.chatReference; + } + + public String getData() { return this.data; } diff --git a/src/main/java/org/qortal/data/network/OnlineAccountData.java b/src/main/java/org/qortal/data/network/OnlineAccountData.java index bd4842db..a1e1b30f 100644 --- a/src/main/java/org/qortal/data/network/OnlineAccountData.java +++ b/src/main/java/org/qortal/data/network/OnlineAccountData.java @@ -1,6 +1,7 @@ package org.qortal.data.network; import java.util.Arrays; +import java.util.Objects; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -34,10 +35,6 @@ public class OnlineAccountData { this.nonce = nonce; } - public OnlineAccountData(long timestamp, byte[] signature, byte[] publicKey) { - this(timestamp, signature, publicKey, null); - } - public long getTimestamp() { return this.timestamp; } @@ -76,6 +73,10 @@ public class OnlineAccountData { if (otherOnlineAccountData.timestamp != this.timestamp) return false; + // Almost as quick + if (!Objects.equals(otherOnlineAccountData.nonce, this.nonce)) + return false; + if (!Arrays.equals(otherOnlineAccountData.publicKey, this.publicKey)) return false; @@ -88,9 +89,10 @@ public class OnlineAccountData { public int hashCode() { int h = this.hash; if (h == 0) { - this.hash = h = Long.hashCode(this.timestamp) - ^ Arrays.hashCode(this.publicKey); + h = Objects.hash(timestamp, nonce); + h = 31 * h + Arrays.hashCode(publicKey); // We don't use signature because newer aggregate signatures use random nonces + this.hash = h; } return h; } diff --git a/src/main/java/org/qortal/data/network/PeerData.java b/src/main/java/org/qortal/data/network/PeerData.java index 09982c00..471685dd 100644 --- a/src/main/java/org/qortal/data/network/PeerData.java +++ b/src/main/java/org/qortal/data/network/PeerData.java @@ -28,6 +28,9 @@ public class PeerData { private Long addedWhen; private String addedBy; + /** The number of consecutive times we failed to sync with this peer */ + private int failedSyncCount = 0; + // Constructors // necessary for JAXB serialization @@ -92,6 +95,18 @@ public class PeerData { return this.addedBy; } + public int getFailedSyncCount() { + return this.failedSyncCount; + } + + public void setFailedSyncCount(int failedSyncCount) { + this.failedSyncCount = failedSyncCount; + } + + public void incrementFailedSyncCount() { + this.failedSyncCount++; + } + // Pretty peerAddress getter for JAXB @XmlElement(name = "address") protected String getPrettyAddress() { diff --git a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java index acd5c3a6..3ab06ecc 100644 --- a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java @@ -73,7 +73,7 @@ public class ArbitraryTransactionData extends TransactionData { @Schema(example = "sender_public_key") private byte[] senderPublicKey; - private Service service; + private int service; private int nonce; private int size; @@ -103,7 +103,7 @@ public class ArbitraryTransactionData extends TransactionData { } public ArbitraryTransactionData(BaseTransactionData baseTransactionData, - int version, Service service, int nonce, int size, + int version, int service, int nonce, int size, String name, String identifier, Method method, byte[] secret, Compression compression, byte[] data, DataType dataType, byte[] metadataHash, List payments) { super(TransactionType.ARBITRARY, baseTransactionData); @@ -135,6 +135,10 @@ public class ArbitraryTransactionData extends TransactionData { } public Service getService() { + return Service.valueOf(this.service); + } + + public int getServiceInt() { return this.service; } diff --git a/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java b/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java index ff3d0a08..14677daf 100644 --- a/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java @@ -3,6 +3,7 @@ package org.qortal.data.transaction; import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; import org.qortal.transaction.Transaction.TransactionType; @@ -19,6 +20,11 @@ public class CancelSellNameTransactionData extends TransactionData { @Schema(description = "which name to cancel selling", example = "my-name") private String name; + // For internal use when orphaning + @XmlTransient + @Schema(hidden = true) + private Long salePrice; + // Constructors // For JAXB @@ -30,11 +36,17 @@ public class CancelSellNameTransactionData extends TransactionData { this.creatorPublicKey = this.ownerPublicKey; } - public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) { + public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name, Long salePrice) { super(TransactionType.CANCEL_SELL_NAME, baseTransactionData); this.ownerPublicKey = baseTransactionData.creatorPublicKey; this.name = name; + this.salePrice = salePrice; + } + + /** From network/API */ + public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) { + this(baseTransactionData, name, null); } // Getters / setters @@ -47,4 +59,12 @@ public class CancelSellNameTransactionData extends TransactionData { return this.name; } + public Long getSalePrice() { + return this.salePrice; + } + + public void setSalePrice(Long salePrice) { + this.salePrice = salePrice; + } + } diff --git a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java index 36ce6124..5a6adf7f 100644 --- a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java @@ -26,6 +26,8 @@ public class ChatTransactionData extends TransactionData { private String recipient; // can be null + private byte[] chatReference; // can be null + @Schema(description = "raw message data, possibly UTF8 text", example = "2yGEbwRFyhPZZckKA") private byte[] data; @@ -44,13 +46,14 @@ public class ChatTransactionData extends TransactionData { } public ChatTransactionData(BaseTransactionData baseTransactionData, - String sender, int nonce, String recipient, byte[] data, boolean isText, boolean isEncrypted) { + String sender, int nonce, String recipient, byte[] chatReference, byte[] data, boolean isText, boolean isEncrypted) { super(TransactionType.CHAT, baseTransactionData); this.senderPublicKey = baseTransactionData.creatorPublicKey; this.sender = sender; this.nonce = nonce; this.recipient = recipient; + this.chatReference = chatReference; this.data = data; this.isText = isText; this.isEncrypted = isEncrypted; @@ -78,6 +81,14 @@ public class ChatTransactionData extends TransactionData { return this.recipient; } + public byte[] getChatReference() { + return this.chatReference; + } + + public void setChatReference(byte[] chatReference) { + this.chatReference = chatReference; + } + public byte[] getData() { return this.data; } diff --git a/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java b/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java index 4df7d79d..8b904aa0 100644 --- a/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java @@ -2,9 +2,11 @@ package org.qortal.data.transaction; import java.util.List; +import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue; import org.qortal.data.voting.PollOptionData; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; @@ -14,8 +16,13 @@ import io.swagger.v3.oas.annotations.media.Schema; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @Schema(allOf = { TransactionData.class }) +@XmlDiscriminatorValue("CREATE_POLL") public class CreatePollTransactionData extends TransactionData { + + @Schema(description = "Poll creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + private byte[] pollCreatorPublicKey; + // Properties private String owner; private String pollName; @@ -29,10 +36,15 @@ public class CreatePollTransactionData extends TransactionData { super(TransactionType.CREATE_POLL); } + public void afterUnmarshal(Unmarshaller u, Object parent) { + this.creatorPublicKey = this.pollCreatorPublicKey; + } + public CreatePollTransactionData(BaseTransactionData baseTransactionData, String owner, String pollName, String description, List pollOptions) { super(Transaction.TransactionType.CREATE_POLL, baseTransactionData); + this.creatorPublicKey = baseTransactionData.creatorPublicKey; this.owner = owner; this.pollName = pollName; this.description = description; @@ -41,6 +53,7 @@ public class CreatePollTransactionData extends TransactionData { // Getters/setters + public byte[] getPollCreatorPublicKey() { return this.creatorPublicKey; } public String getOwner() { return this.owner; } diff --git a/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java b/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java index 7a2ebdab..fed69cd5 100644 --- a/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java @@ -2,6 +2,7 @@ package org.qortal.data.transaction; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import org.qortal.transaction.Transaction.TransactionType; @@ -90,4 +91,17 @@ public class DeployAtTransactionData extends TransactionData { this.aTAddress = AtAddress; } + // Re-expose creatorPublicKey for this transaction type for JAXB + @XmlElement(name = "creatorPublicKey") + @Schema(name = "creatorPublicKey", description = "AT creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + public byte[] getAtCreatorPublicKey() { + return this.creatorPublicKey; + } + + @XmlElement(name = "creatorPublicKey") + @Schema(name = "creatorPublicKey", description = "AT creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + public void setAtCreatorPublicKey(byte[] creatorPublicKey) { + this.creatorPublicKey = creatorPublicKey; + } + } diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index 060901f2..c4a115df 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -12,6 +12,8 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorNode; import org.qortal.crypto.Crypto; +import org.qortal.data.voting.PollData; +import org.qortal.data.voting.VoteOnPollData; import org.qortal.transaction.Transaction.ApprovalStatus; import org.qortal.transaction.Transaction.TransactionType; @@ -29,6 +31,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode; @XmlSeeAlso({GenesisTransactionData.class, PaymentTransactionData.class, RegisterNameTransactionData.class, UpdateNameTransactionData.class, SellNameTransactionData.class, CancelSellNameTransactionData.class, BuyNameTransactionData.class, CreatePollTransactionData.class, VoteOnPollTransactionData.class, ArbitraryTransactionData.class, + PollData.class, VoteOnPollData.class, IssueAssetTransactionData.class, TransferAssetTransactionData.class, CreateAssetOrderTransactionData.class, CancelAssetOrderTransactionData.class, MultiPaymentTransactionData.class, DeployAtTransactionData.class, MessageTransactionData.class, ATTransactionData.class, @@ -76,6 +79,10 @@ public abstract class TransactionData { @Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "height of block containing transaction") protected Integer blockHeight; + // Not always present + @Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "sequence in block containing transaction") + protected Integer blockSequence; + // Not always present @Schema(accessMode = AccessMode.READ_ONLY, description = "group-approval status") protected ApprovalStatus approvalStatus; @@ -106,6 +113,7 @@ public abstract class TransactionData { this.fee = baseTransactionData.fee; this.signature = baseTransactionData.signature; this.blockHeight = baseTransactionData.blockHeight; + this.blockSequence = baseTransactionData.blockSequence; this.approvalStatus = baseTransactionData.approvalStatus; this.approvalHeight = baseTransactionData.approvalHeight; } @@ -128,6 +136,10 @@ public abstract class TransactionData { return this.txGroupId; } + public void setTxGroupId(int txGroupId) { + this.txGroupId = txGroupId; + } + public byte[] getReference() { return this.reference; } @@ -170,6 +182,15 @@ public abstract class TransactionData { this.blockHeight = blockHeight; } + public Integer getBlockSequence() { + return this.blockSequence; + } + + @XmlTransient + public void setBlockSequence(Integer blockSequence) { + this.blockSequence = blockSequence; + } + public ApprovalStatus getApprovalStatus() { return approvalStatus; } diff --git a/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java b/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java index 6145d741..a23d5e2b 100644 --- a/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java @@ -3,7 +3,9 @@ package org.qortal.data.transaction; import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; +import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue; import org.qortal.transaction.Transaction.TransactionType; import io.swagger.v3.oas.annotations.media.Schema; @@ -11,12 +13,17 @@ import io.swagger.v3.oas.annotations.media.Schema; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @Schema(allOf = { TransactionData.class }) +@XmlDiscriminatorValue("VOTE_ON_POLL") public class VoteOnPollTransactionData extends TransactionData { // Properties + @Schema(description = "Vote creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") private byte[] voterPublicKey; private String pollName; private int optionIndex; + // For internal use when orphaning + @XmlTransient + @Schema(hidden = true) private Integer previousOptionIndex; // Constructors diff --git a/src/main/java/org/qortal/data/voting/PollData.java b/src/main/java/org/qortal/data/voting/PollData.java index 4af62087..1850ddc7 100644 --- a/src/main/java/org/qortal/data/voting/PollData.java +++ b/src/main/java/org/qortal/data/voting/PollData.java @@ -14,6 +14,11 @@ public class PollData { // Constructors + // For JAXB + protected PollData() { + super(); + } + public PollData(byte[] creatorPublicKey, String owner, String pollName, String description, List pollOptions, long published) { this.creatorPublicKey = creatorPublicKey; this.owner = owner; @@ -29,22 +34,42 @@ public class PollData { return this.creatorPublicKey; } + public void setCreatorPublicKey(byte[] creatorPublicKey) { + this.creatorPublicKey = creatorPublicKey; + } + public String getOwner() { return this.owner; } + public void setOwner(String owner) { + this.owner = owner; + } + public String getPollName() { return this.pollName; } + public void setPollName(String pollName) { + this.pollName = pollName; + } + public String getDescription() { return this.description; } + public void setDescription(String description) { + this.description = description; + } + public List getPollOptions() { return this.pollOptions; } + public void setPollOptions(List pollOptions) { + this.pollOptions = pollOptions; + } + public long getPublished() { return this.published; } diff --git a/src/main/java/org/qortal/data/voting/VoteOnPollData.java b/src/main/java/org/qortal/data/voting/VoteOnPollData.java index 47c06a54..531ed286 100644 --- a/src/main/java/org/qortal/data/voting/VoteOnPollData.java +++ b/src/main/java/org/qortal/data/voting/VoteOnPollData.java @@ -9,6 +9,11 @@ public class VoteOnPollData { // Constructors + // For JAXB + protected VoteOnPollData() { + super(); + } + public VoteOnPollData(String pollName, byte[] voterPublicKey, int optionIndex) { this.pollName = pollName; this.voterPublicKey = voterPublicKey; @@ -21,12 +26,24 @@ public class VoteOnPollData { return this.pollName; } + public void setPollName(String pollName) { + this.pollName = pollName; + } + public byte[] getVoterPublicKey() { return this.voterPublicKey; } + public void setVoterPublicKey(byte[] voterPublicKey) { + this.voterPublicKey = voterPublicKey; + } + public int getOptionIndex() { return this.optionIndex; } + public void setOptionIndex(int optionIndex) { + this.optionIndex = optionIndex; + } + } diff --git a/src/main/java/org/qortal/group/Group.java b/src/main/java/org/qortal/group/Group.java index 1dbb18b0..465743a9 100644 --- a/src/main/java/org/qortal/group/Group.java +++ b/src/main/java/org/qortal/group/Group.java @@ -80,6 +80,9 @@ public class Group { // Useful constants public static final int NO_GROUP = 0; + // Null owner address corresponds with public key "11111111111111111111111111111111" + public static String NULL_OWNER_ADDRESS = "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG"; + public static final int MIN_NAME_SIZE = 3; public static final int MAX_NAME_SIZE = 32; public static final int MAX_DESCRIPTION_SIZE = 128; diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 099aa168..855c9068 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -9,6 +9,7 @@ import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -52,6 +53,15 @@ public class ResourceList { String jsonString = ResourceList.listToJSONString(this.list); Path filePath = this.getFilePath(); + // Don't create list if it's empty + if (this.list != null && this.list.isEmpty()) { + if (filePath != null && Files.exists(filePath)) { + // Delete empty list + Files.delete(filePath); + } + return; + } + // Create parent directory if needed try { Files.createDirectories(filePath.getParent()); @@ -72,7 +82,7 @@ public class ResourceList { } try { - String jsonString = new String(Files.readAllBytes(path)); + String jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); this.list = ResourceList.listFromJSONString(jsonString); } catch (IOException e) { throw new IOException(String.format("Couldn't read contents from file %s", path.toString())); @@ -109,6 +119,13 @@ public class ResourceList { this.list.remove(resource); } + public void clear() { + if (this.list == null) { + return; + } + this.list.clear(); + } + public boolean contains(String resource, boolean caseSensitive) { if (resource == null || this.list == null) { return false; diff --git a/src/main/java/org/qortal/list/ResourceListManager.java b/src/main/java/org/qortal/list/ResourceListManager.java index 4182f87c..f694ebd5 100644 --- a/src/main/java/org/qortal/list/ResourceListManager.java +++ b/src/main/java/org/qortal/list/ResourceListManager.java @@ -2,8 +2,11 @@ package org.qortal.list; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.settings.Settings; import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -18,6 +21,7 @@ public class ResourceListManager { public ResourceListManager() { + this.lists = this.fetchLists(); } public static synchronized ResourceListManager getInstance() { @@ -27,6 +31,38 @@ public class ResourceListManager { return instance; } + public static synchronized void reset() { + if (instance != null) { + instance = null; + } + } + + private List fetchLists() { + List lists = new ArrayList<>(); + Path listsPath = Paths.get(Settings.getInstance().getListsPath()); + + if (listsPath.toFile().isDirectory()) { + String[] files = listsPath.toFile().list(); + + for (String fileName : files) { + try { + // Remove .json extension + if (fileName.endsWith(".json")) { + fileName = fileName.substring(0, fileName.length() - 5); + } + + ResourceList list = new ResourceList(fileName); + if (list != null) { + lists.add(list); + } + } catch (IOException e) { + // Ignore this list + } + } + } + return lists; + } + private ResourceList getList(String listName) { for (ResourceList list : this.lists) { if (Objects.equals(list.getName(), listName)) { @@ -48,6 +84,18 @@ public class ResourceListManager { } + private List getListsByPrefix(String listNamePrefix) { + List lists = new ArrayList<>(); + + for (ResourceList list : this.lists) { + if (list != null && list.getName() != null && list.getName().startsWith(listNamePrefix)) { + lists.add(list); + } + } + + return lists; + } + public boolean addToList(String listName, String item, boolean save) { ResourceList list = this.getList(listName); if (list == null) { @@ -95,6 +143,16 @@ public class ResourceListManager { return list.contains(item, caseSensitive); } + public boolean listWithPrefixContains(String listNamePrefix, String item, boolean caseSensitive) { + List lists = getListsByPrefix(listNamePrefix); + for (ResourceList list : lists) { + if (list.contains(item, caseSensitive)) { + return true; + } + } + return false; + } + public void saveList(String listName) { ResourceList list = this.getList(listName); if (list == null) { @@ -133,6 +191,15 @@ public class ResourceListManager { return list.getList(); } + public List getStringsInListsWithPrefix(String listNamePrefix) { + List items = new ArrayList<>(); + List lists = getListsByPrefix(listNamePrefix); + for (ResourceList list : lists) { + items.addAll(list.getList()); + } + return items; + } + public int getItemCountForList(String listName) { ResourceList list = this.getList(listName); if (list == null) { diff --git a/src/main/java/org/qortal/naming/Name.java b/src/main/java/org/qortal/naming/Name.java index 97fe8bbb..1751cca8 100644 --- a/src/main/java/org/qortal/naming/Name.java +++ b/src/main/java/org/qortal/naming/Name.java @@ -16,6 +16,8 @@ import org.qortal.repository.Repository; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.Unicode; +import java.util.Objects; + public class Name { // Properties @@ -116,7 +118,7 @@ public class Name { this.repository.getNameRepository().save(this.nameData); - if (!updateNameTransactionData.getNewName().isEmpty()) + if (!updateNameTransactionData.getNewName().isEmpty() && !Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) // Name has changed, delete old entry this.repository.getNameRepository().delete(updateNameTransactionData.getNewName()); @@ -180,8 +182,12 @@ public class Name { } public void cancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException { - // Mark not for-sale but leave price in case we want to orphan + // Update previous sale price in transaction data + cancelSellNameTransactionData.setSalePrice(this.nameData.getSalePrice()); + + // Mark not for-sale this.nameData.setIsForSale(false); + this.nameData.setSalePrice(null); // Save sale info into repository this.repository.getNameRepository().save(this.nameData); @@ -190,6 +196,7 @@ public class Name { public void uncancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException { // Mark as for-sale using existing price this.nameData.setIsForSale(true); + this.nameData.setSalePrice(cancelSellNameTransactionData.getSalePrice()); // Save no-sale info into repository this.repository.getNameRepository().save(this.nameData); diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index 22354cc4..341f4e21 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -265,7 +265,7 @@ public enum Handshake { private static final long PEER_VERSION_131 = 0x0100030001L; /** Minimum peer version that we are allowed to communicate with */ - private static final String MIN_PEER_VERSION = "3.1.0"; + private static final String MIN_PEER_VERSION = "4.1.1"; private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index 8aac68f0..a3528a66 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -124,6 +124,8 @@ public class Network { private final List selfPeers = new ArrayList<>(); + private String bindAddress = null; + private final ExecuteProduceConsume networkEPC; private Selector channelSelector; private ServerSocketChannel serverChannel; @@ -159,25 +161,43 @@ public class Network { // Grab P2P port from settings int listenPort = Settings.getInstance().getListenPort(); - // Grab P2P bind address from settings - try { - InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); - InetSocketAddress endpoint = new InetSocketAddress(bindAddr, listenPort); + // Grab P2P bind addresses from settings + List bindAddresses = new ArrayList<>(); + if (Settings.getInstance().getBindAddress() != null) { + bindAddresses.add(Settings.getInstance().getBindAddress()); + } + if (Settings.getInstance().getBindAddressFallback() != null) { + bindAddresses.add(Settings.getInstance().getBindAddressFallback()); + } - channelSelector = Selector.open(); + for (int i=0; i blockSummaries = new ArrayList<>(); + + // If there are no bytes remaining then we can treat this as an empty array of summaries + if (bytes.remaining() == 0) + return new BlockSummariesV2Message(id, blockSummaries); + int height = bytes.getInt(); // Expecting bytes remaining to be exact multiples of BLOCK_SUMMARY_V2_LENGTH if (bytes.remaining() % BLOCK_SUMMARY_V2_LENGTH != 0) throw new BufferUnderflowException(); - List blockSummaries = new ArrayList<>(); while (bytes.hasRemaining()) { byte[] signature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; bytes.get(signature); diff --git a/src/main/java/org/qortal/network/message/CachedBlockV2Message.java b/src/main/java/org/qortal/network/message/CachedBlockV2Message.java new file mode 100644 index 00000000..c981293d --- /dev/null +++ b/src/main/java/org/qortal/network/message/CachedBlockV2Message.java @@ -0,0 +1,43 @@ +package org.qortal.network.message; + +import com.google.common.primitives.Ints; +import org.qortal.block.Block; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +// This is an OUTGOING-only Message which more readily lends itself to being cached +public class CachedBlockV2Message extends Message implements Cloneable { + + public CachedBlockV2Message(Block block) throws TransformationException { + super(MessageType.BLOCK_V2); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + + bytes.write(BlockTransformer.toBytes(block)); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } + + public CachedBlockV2Message(byte[] cachedBytes) { + super(MessageType.BLOCK_V2); + + this.dataBytes = cachedBytes; + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } + + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) { + throw new UnsupportedOperationException("CachedBlockMessageV2 is for outgoing messages only"); + } + +} diff --git a/src/main/java/org/qortal/network/message/GenericUnknownMessage.java b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java index 15faaa1b..dea9f2b8 100644 --- a/src/main/java/org/qortal/network/message/GenericUnknownMessage.java +++ b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java @@ -4,7 +4,7 @@ import java.nio.ByteBuffer; public class GenericUnknownMessage extends Message { - public static final long MINIMUM_PEER_VERSION = 0x03000400cbL; + public static final long MINIMUM_PEER_VERSION = 0x0300060001L; public GenericUnknownMessage() { super(MessageType.GENERIC_UNKNOWN); diff --git a/src/main/java/org/qortal/network/message/GetOnlineAccountsMessage.java b/src/main/java/org/qortal/network/message/GetOnlineAccountsMessage.java deleted file mode 100644 index ae98cf40..00000000 --- a/src/main/java/org/qortal/network/message/GetOnlineAccountsMessage.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.qortal.network.message; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; - -public class GetOnlineAccountsMessage extends Message { - private static final int MAX_ACCOUNT_COUNT = 5000; - - private List onlineAccounts; - - public GetOnlineAccountsMessage(List onlineAccounts) { - super(MessageType.GET_ONLINE_ACCOUNTS); - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - try { - bytes.write(Ints.toByteArray(onlineAccounts.size())); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp())); - - bytes.write(onlineAccountData.getPublicKey()); - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private GetOnlineAccountsMessage(int id, List onlineAccounts) { - super(id, MessageType.GET_ONLINE_ACCOUNTS); - - this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList()); - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) { - final int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) { - long timestamp = bytes.getLong(); - - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey)); - } - - return new GetOnlineAccountsMessage(id, onlineAccounts); - } - -} diff --git a/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java b/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java deleted file mode 100644 index fe6b5d72..00000000 --- a/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.qortal.network.message; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * For requesting online accounts info from remote peer, given our list of online accounts. - * - * Different format to V1: - * V1 is: number of entries, then timestamp + pubkey for each entry - * V2 is: groups of: number of entries, timestamp, then pubkey for each entry - * - * Also V2 only builds online accounts message once! - */ -public class GetOnlineAccountsV2Message extends Message { - - private List onlineAccounts; - - public GetOnlineAccountsV2Message(List onlineAccounts) { - super(MessageType.GET_ONLINE_ACCOUNTS_V2); - - // If we don't have ANY online accounts then it's an easier construction... - if (onlineAccounts.isEmpty()) { - // Always supply a number of accounts - this.dataBytes = Ints.toByteArray(0); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - return; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - Long timestamp = onlineAccountData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + onlineAccounts.size() * Transformer.PUBLIC_KEY_LENGTH; - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - try { - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - if (onlineAccountData.getTimestamp() == timestamp) - bytes.write(onlineAccountData.getPublicKey()); - } - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private GetOnlineAccountsV2Message(int id, List onlineAccounts) { - super(id, MessageType.GET_ONLINE_ACCOUNTS_V2); - - this.onlineAccounts = onlineAccounts; - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) { - int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - while (accountCount > 0) { - long timestamp = bytes.getLong(); - - for (int i = 0; i < accountCount; ++i) { - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey)); - } - - if (bytes.hasRemaining()) { - accountCount = bytes.getInt(); - } else { - // we've finished - accountCount = 0; - } - } - - return new GetOnlineAccountsV2Message(id, onlineAccounts); - } - -} diff --git a/src/main/java/org/qortal/network/message/MessageType.java b/src/main/java/org/qortal/network/message/MessageType.java index 4dd4a3c8..6b420e2d 100644 --- a/src/main/java/org/qortal/network/message/MessageType.java +++ b/src/main/java/org/qortal/network/message/MessageType.java @@ -43,11 +43,7 @@ public enum MessageType { BLOCK_SUMMARIES(70, BlockSummariesMessage::fromByteBuffer), GET_BLOCK_SUMMARIES(71, GetBlockSummariesMessage::fromByteBuffer), BLOCK_SUMMARIES_V2(72, BlockSummariesV2Message::fromByteBuffer), - - ONLINE_ACCOUNTS(80, OnlineAccountsMessage::fromByteBuffer), - GET_ONLINE_ACCOUNTS(81, GetOnlineAccountsMessage::fromByteBuffer), - ONLINE_ACCOUNTS_V2(82, OnlineAccountsV2Message::fromByteBuffer), - GET_ONLINE_ACCOUNTS_V2(83, GetOnlineAccountsV2Message::fromByteBuffer), + ONLINE_ACCOUNTS_V3(84, OnlineAccountsV3Message::fromByteBuffer), GET_ONLINE_ACCOUNTS_V3(85, GetOnlineAccountsV3Message::fromByteBuffer), diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsMessage.java b/src/main/java/org/qortal/network/message/OnlineAccountsMessage.java deleted file mode 100644 index e7e4c32c..00000000 --- a/src/main/java/org/qortal/network/message/OnlineAccountsMessage.java +++ /dev/null @@ -1,75 +0,0 @@ -package org.qortal.network.message; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; - -public class OnlineAccountsMessage extends Message { - private static final int MAX_ACCOUNT_COUNT = 5000; - - private List onlineAccounts; - - public OnlineAccountsMessage(List onlineAccounts) { - super(MessageType.ONLINE_ACCOUNTS); - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - try { - bytes.write(Ints.toByteArray(onlineAccounts.size())); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp())); - - bytes.write(onlineAccountData.getSignature()); - - bytes.write(onlineAccountData.getPublicKey()); - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private OnlineAccountsMessage(int id, List onlineAccounts) { - super(id, MessageType.ONLINE_ACCOUNTS); - - this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList()); - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) { - final int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) { - long timestamp = bytes.getLong(); - - byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; - bytes.get(signature); - - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - OnlineAccountData onlineAccountData = new OnlineAccountData(timestamp, signature, publicKey); - onlineAccounts.add(onlineAccountData); - } - - return new OnlineAccountsMessage(id, onlineAccounts); - } - -} diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java b/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java deleted file mode 100644 index 6803e3bf..00000000 --- a/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java +++ /dev/null @@ -1,113 +0,0 @@ -package org.qortal.network.message; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * For sending online accounts info to remote peer. - * - * Different format to V1: - * V1 is: number of entries, then timestamp + sig + pubkey for each entry - * V2 is: groups of: number of entries, timestamp, then sig + pubkey for each entry - * - * Also V2 only builds online accounts message once! - */ -public class OnlineAccountsV2Message extends Message { - - private List onlineAccounts; - - public OnlineAccountsV2Message(List onlineAccounts) { - super(MessageType.ONLINE_ACCOUNTS_V2); - - // Shortcut in case we have no online accounts - if (onlineAccounts.isEmpty()) { - this.dataBytes = Ints.toByteArray(0); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - return; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - Long timestamp = onlineAccountData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + onlineAccounts.size() * (Transformer.SIGNATURE_LENGTH + Transformer.PUBLIC_KEY_LENGTH); - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - try { - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - if (onlineAccountData.getTimestamp() == timestamp) { - bytes.write(onlineAccountData.getSignature()); - bytes.write(onlineAccountData.getPublicKey()); - } - } - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private OnlineAccountsV2Message(int id, List onlineAccounts) { - super(id, MessageType.ONLINE_ACCOUNTS_V2); - - this.onlineAccounts = onlineAccounts; - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException { - int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - while (accountCount > 0) { - long timestamp = bytes.getLong(); - - for (int i = 0; i < accountCount; ++i) { - byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; - bytes.get(signature); - - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey)); - } - - if (bytes.hasRemaining()) { - accountCount = bytes.getInt(); - } else { - // we've finished - accountCount = 0; - } - } - - return new OnlineAccountsV2Message(id, onlineAccounts); - } - -} diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java b/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java index d554d96c..c057fbce 100644 --- a/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java +++ b/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java @@ -99,9 +99,10 @@ public class OnlineAccountsV3Message extends Message { bytes.get(publicKey); // Nonce is optional - will be -1 if missing + // ... but we should skip/ignore an online account if it has no nonce Integer nonce = bytes.getInt(); if (nonce < 0) { - nonce = null; + continue; } onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey, nonce)); diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 0f537ae9..93da924c 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -119,7 +119,7 @@ public interface ATRepository { *

* NOTE: performs implicit repository.saveChanges(). */ - public void rebuildLatestAtStates() throws DataException; + public void rebuildLatestAtStates(int maxHeight) throws DataException; /** Returns height of first trimmable AT state. */ diff --git a/src/main/java/org/qortal/repository/AccountRepository.java b/src/main/java/org/qortal/repository/AccountRepository.java index 281f34f1..1175337c 100644 --- a/src/main/java/org/qortal/repository/AccountRepository.java +++ b/src/main/java/org/qortal/repository/AccountRepository.java @@ -1,13 +1,9 @@ package org.qortal.repository; import java.util.List; +import java.util.Set; -import org.qortal.data.account.AccountBalanceData; -import org.qortal.data.account.AccountData; -import org.qortal.data.account.EligibleQoraHolderData; -import org.qortal.data.account.MintingAccountData; -import org.qortal.data.account.QortFromQoraData; -import org.qortal.data.account.RewardShareData; +import org.qortal.data.account.*; public interface AccountRepository { @@ -19,6 +15,9 @@ public interface AccountRepository { /** Returns accounts with any bit set in given mask. */ public List getFlaggedAccounts(int mask) throws DataException; + /** Returns accounts with a blockedMintedPenalty */ + public List getPenaltyAccounts() throws DataException; + /** Returns account's last reference or null if not set or account not found. */ public byte[] getLastReference(String address) throws DataException; @@ -100,6 +99,18 @@ public interface AccountRepository { */ public void modifyMintedBlockCounts(List addresses, int delta) throws DataException; + /** Returns account's block minted penalty count or null if account not found. */ + public Integer getBlocksMintedPenaltyCount(String address) throws DataException; + + /** + * Sets blocks minted penalties for given list of accounts. + * This replaces the existing values rather than modifying them by a delta. + * + * @param accountPenalties + * @throws DataException + */ + public void updateBlocksMintedPenalties(Set accountPenalties) throws DataException; + /** Delete account from repository. */ public void delete(String address) throws DataException; diff --git a/src/main/java/org/qortal/repository/ArbitraryRepository.java b/src/main/java/org/qortal/repository/ArbitraryRepository.java index 75fb0509..9d9ed8ce 100644 --- a/src/main/java/org/qortal/repository/ArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/ArbitraryRepository.java @@ -24,9 +24,9 @@ public interface ArbitraryRepository { public ArbitraryTransactionData getLatestTransaction(String name, Service service, Method method, String identifier) throws DataException; - public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException; - public List searchArbitraryResources(Service service, String query, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, List namesFilter, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException; public List getArbitraryResourceCreatorNames(Service service, String identifier, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index 311d21c7..1f04bced 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -3,10 +3,7 @@ package org.qortal.repository; import com.google.common.primitives.Ints; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockArchiveData; -import org.qortal.data.block.BlockData; -import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; import org.qortal.transform.TransformationException; import org.qortal.transform.block.BlockTransformation; @@ -67,20 +64,51 @@ public class BlockArchiveReader { this.fileListCache = Map.copyOf(map); } + public Integer fetchSerializationVersionForHeight(int height) { + if (this.fileListCache == null) { + this.fetchFileList(); + } + + Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBlock == null) { + return null; + } + Integer serializationVersion = serializedBlock.getB(); + return serializationVersion; + } + public BlockTransformation fetchBlockAtHeight(int height) { if (this.fileListCache == null) { this.fetchFileList(); } - byte[] serializedBytes = this.fetchSerializedBlockBytesForHeight(height); - if (serializedBytes == null) { + Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBlock == null) { + return null; + } + byte[] serializedBytes = serializedBlock.getA(); + Integer serializationVersion = serializedBlock.getB(); + if (serializedBytes == null || serializationVersion == null) { return null; } ByteBuffer byteBuffer = ByteBuffer.wrap(serializedBytes); BlockTransformation blockInfo = null; try { - blockInfo = BlockTransformer.fromByteBuffer(byteBuffer); + switch (serializationVersion) { + case 1: + blockInfo = BlockTransformer.fromByteBuffer(byteBuffer); + break; + + case 2: + blockInfo = BlockTransformer.fromByteBufferV2(byteBuffer); + break; + + default: + // Invalid serialization version + return null; + } + if (blockInfo != null && blockInfo.getBlockData() != null) { // Block height is stored outside of the main serialized bytes, so it // won't be set automatically. @@ -168,15 +196,20 @@ public class BlockArchiveReader { return null; } - public byte[] fetchSerializedBlockBytesForSignature(byte[] signature, boolean includeHeightPrefix, Repository repository) { + public Triple fetchSerializedBlockBytesForSignature(byte[] signature, boolean includeHeightPrefix, Repository repository) { if (this.fileListCache == null) { this.fetchFileList(); } Integer height = this.fetchHeightForSignature(signature, repository); if (height != null) { - byte[] blockBytes = this.fetchSerializedBlockBytesForHeight(height); - if (blockBytes == null) { + Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBlock == null) { + return null; + } + byte[] blockBytes = serializedBlock.getA(); + Integer version = serializedBlock.getB(); + if (blockBytes == null || version == null) { return null; } @@ -187,18 +220,18 @@ public class BlockArchiveReader { try { bytes.write(Ints.toByteArray(height)); bytes.write(blockBytes); - return bytes.toByteArray(); + return new Triple<>(bytes.toByteArray(), version, height); } catch (IOException e) { return null; } } - return blockBytes; + return new Triple<>(blockBytes, version, height); } return null; } - public byte[] fetchSerializedBlockBytesForHeight(int height) { + public Triple fetchSerializedBlockBytesForHeight(int height) { String filename = this.getFilenameForHeight(height); if (filename == null) { // We don't have this block in the archive @@ -221,7 +254,7 @@ public class BlockArchiveReader { // End of fixed length header // Make sure the version is one we recognize - if (version != 1) { + if (version != 1 && version != 2) { LOGGER.info("Error: unknown version in file {}: {}", filename, version); return null; } @@ -258,7 +291,7 @@ public class BlockArchiveReader { byte[] blockBytes = new byte[blockLength]; file.read(blockBytes); - return blockBytes; + return new Triple<>(blockBytes, version, height); } catch (FileNotFoundException e) { LOGGER.info("File {} not found: {}", filename, e.getMessage()); @@ -279,6 +312,30 @@ public class BlockArchiveReader { } } + public int getHeightOfLastArchivedBlock() { + if (this.fileListCache == null) { + this.fetchFileList(); + } + + int maxEndHeight = 0; + + Iterator it = this.fileListCache.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry pair = (Map.Entry) it.next(); + if (pair == null && pair.getKey() == null && pair.getValue() == null) { + continue; + } + Triple heightInfo = (Triple) pair.getValue(); + Integer endHeight = heightInfo.getB(); + + if (endHeight != null && endHeight > maxEndHeight) { + maxEndHeight = endHeight; + } + } + + return maxEndHeight; + } + public void invalidateFileListCache() { this.fileListCache = null; } diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 5127bf9b..e47aabbd 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -6,10 +6,13 @@ import org.apache.logging.log4j.Logger; import org.qortal.block.Block; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; +import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockArchiveData; import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformation; import org.qortal.transform.block.BlockTransformer; import java.io.ByteArrayOutputStream; @@ -18,6 +21,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; public class BlockArchiveWriter { @@ -28,25 +32,78 @@ public class BlockArchiveWriter { BLOCK_NOT_FOUND } + public enum BlockArchiveDataSource { + BLOCK_REPOSITORY, // To build an archive from the Blocks table + BLOCK_ARCHIVE // To build a new archive from an existing archive + } + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveWriter.class); - public static final long DEFAULT_FILE_SIZE_TARGET = 100 * 1024 * 1024; // 100MiB + public static final long DEFAULT_FILE_SIZE_TARGET_V1 = 100 * 1024 * 1024; // 100MiB + public static final long DEFAULT_FILE_SIZE_TARGET_V2 = 10 * 1024 * 1024; // 10MiB private int startHeight; private final int endHeight; + private final Integer serializationVersion; + private final Path archivePath; private final Repository repository; - private long fileSizeTarget = DEFAULT_FILE_SIZE_TARGET; + private long fileSizeTarget = DEFAULT_FILE_SIZE_TARGET_V1; private boolean shouldEnforceFileSizeTarget = true; + // Default data source to BLOCK_REPOSITORY; can optionally be overridden + private BlockArchiveDataSource dataSource = BlockArchiveDataSource.BLOCK_REPOSITORY; + + private boolean shouldLogProgress = false; + private int writtenCount; private int lastWrittenHeight; private Path outputPath; - public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { + /** + * Instantiate a BlockArchiveWriter using a custom archive path + * @param startHeight + * @param endHeight + * @param repository + */ + public BlockArchiveWriter(int startHeight, int endHeight, Integer serializationVersion, Path archivePath, Repository repository) { this.startHeight = startHeight; this.endHeight = endHeight; + this.archivePath = archivePath.toAbsolutePath(); this.repository = repository; + + if (serializationVersion == null) { + // When serialization version isn't specified, fetch it from the existing archive + serializationVersion = this.findSerializationVersion(); + } + + // Reduce default file size target if we're using V2, as the average block size is over 90% smaller + if (serializationVersion == 2) { + this.setFileSizeTarget(DEFAULT_FILE_SIZE_TARGET_V2); + } + + this.serializationVersion = serializationVersion; + } + + /** + * Instantiate a BlockArchiveWriter using the default archive path and version + * @param startHeight + * @param endHeight + * @param repository + */ + public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { + this(startHeight, endHeight, null, Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); + } + + private int findSerializationVersion() { + // Attempt to fetch the serialization version from the existing archive + Integer block2SerializationVersion = BlockArchiveReader.getInstance().fetchSerializationVersionForHeight(2); + if (block2SerializationVersion != null) { + return block2SerializationVersion; + } + + // Default to version specified in settings + return Settings.getInstance().getDefaultArchiveVersion(); } public static int getMaxArchiveHeight(Repository repository) throws DataException { @@ -72,8 +129,7 @@ public class BlockArchiveWriter { public BlockArchiveWriteResult write() throws DataException, IOException, TransformationException, InterruptedException { // Create the archive folder if it doesn't exist - // This is a subfolder of the db directory, to make bootstrapping easier - Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + // This is generally a subfolder of the db directory, to make bootstrapping easier try { Files.createDirectories(archivePath); } catch (IOException e) { @@ -95,13 +151,13 @@ public class BlockArchiveWriter { LOGGER.info(String.format("Fetching blocks from height %d...", startHeight)); int i = 0; - while (headerBytes.size() + bytes.size() < this.fileSizeTarget - || this.shouldEnforceFileSizeTarget == false) { + while (headerBytes.size() + bytes.size() < this.fileSizeTarget) { if (Controller.isStopping()) { return BlockArchiveWriteResult.STOPPING; } if (Synchronizer.getInstance().isSynchronizing()) { + Thread.sleep(1000L); continue; } @@ -112,7 +168,28 @@ public class BlockArchiveWriter { //LOGGER.info("Fetching block {}...", currentHeight); - BlockData blockData = repository.getBlockRepository().fromHeight(currentHeight); + BlockData blockData = null; + List transactions = null; + List atStates = null; + byte[] atStatesHash = null; + + switch (this.dataSource) { + case BLOCK_ARCHIVE: + BlockTransformation archivedBlock = BlockArchiveReader.getInstance().fetchBlockAtHeight(currentHeight); + if (archivedBlock != null) { + blockData = archivedBlock.getBlockData(); + transactions = archivedBlock.getTransactions(); + atStates = archivedBlock.getAtStates(); + atStatesHash = archivedBlock.getAtStatesHash(); + } + break; + + case BLOCK_REPOSITORY: + default: + blockData = repository.getBlockRepository().fromHeight(currentHeight); + break; + } + if (blockData == null) { return BlockArchiveWriteResult.BLOCK_NOT_FOUND; } @@ -122,18 +199,50 @@ public class BlockArchiveWriter { repository.getBlockArchiveRepository().save(blockArchiveData); repository.saveChanges(); + // Build the block + Block block; + if (atStatesHash != null) { + block = new Block(repository, blockData, transactions, atStatesHash); + } + else if (atStates != null) { + block = new Block(repository, blockData, transactions, atStates); + } + else { + block = new Block(repository, blockData); + } + // Write the block data to some byte buffers - Block block = new Block(repository, blockData); int blockIndex = bytes.size(); // Write block index to header headerBytes.write(Ints.toByteArray(blockIndex)); // Write block height bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); - byte[] blockBytes = BlockTransformer.toBytes(block); + + // Get serialized block bytes + byte[] blockBytes; + switch (serializationVersion) { + case 1: + blockBytes = BlockTransformer.toBytes(block); + break; + + case 2: + blockBytes = BlockTransformer.toBytesV2(block); + break; + + default: + throw new DataException("Invalid serialization version"); + } + // Write block length bytes.write(Ints.toByteArray(blockBytes.length)); // Write block bytes bytes.write(blockBytes); + + // Log every 1000 blocks + if (this.shouldLogProgress && i % 1000 == 0) { + LOGGER.info("Archived up to block height {}. Size of current file: {} bytes", currentHeight, (headerBytes.size() + bytes.size())); + } + i++; } @@ -147,11 +256,10 @@ public class BlockArchiveWriter { // We have enough blocks to create a new file int endHeight = startHeight + i - 1; - int version = 1; String filePath = String.format("%s/%d-%d.dat", archivePath.toString(), startHeight, endHeight); FileOutputStream fileOutputStream = new FileOutputStream(filePath); // Write version number - fileOutputStream.write(Ints.toByteArray(version)); + fileOutputStream.write(Ints.toByteArray(serializationVersion)); // Write start height fileOutputStream.write(Ints.toByteArray(startHeight)); // Write end height @@ -199,4 +307,12 @@ public class BlockArchiveWriter { this.shouldEnforceFileSizeTarget = shouldEnforceFileSizeTarget; } + public void setDataSource(BlockArchiveDataSource dataSource) { + this.dataSource = dataSource; + } + + public void setShouldLogProgress(boolean shouldLogProgress) { + this.shouldLogProgress = shouldLogProgress; + } + } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 626433e8..2d2605cc 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -279,7 +279,9 @@ public class Bootstrap { LOGGER.info("Generating checksum file..."); String checksum = Crypto.digestHexString(compressedOutputPath.toFile(), 1024*1024); + LOGGER.info("checksum: {}", checksum); Path checksumPath = Paths.get(String.format("%s.sha256", compressedOutputPath.toString())); + LOGGER.info("Writing checksum to path: {}", checksumPath); Files.writeString(checksumPath, checksum, StandardOpenOption.CREATE); // Return the path to the compressed bootstrap file diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index 2ecd8a34..7443fb51 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -6,6 +6,8 @@ import org.qortal.data.chat.ActiveChats; import org.qortal.data.chat.ChatMessage; import org.qortal.data.transaction.ChatTransactionData; +import static org.qortal.data.chat.ChatMessage.Encoding; + public interface ChatRepository { /** @@ -14,11 +16,12 @@ public interface ChatRepository { * Expects EITHER non-null txGroupID OR non-null sender and recipient addresses. */ public List getMessagesMatchingCriteria(Long before, Long after, - Integer txGroupId, byte[] reference, List involving, + Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, Boolean hasChatReference, + List involving, String senderAddress, Encoding encoding, Integer limit, Integer offset, Boolean reverse) throws DataException; - public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) throws DataException; + public ChatMessage toChatMessage(ChatTransactionData chatTransactionData, Encoding encoding) throws DataException; - public ActiveChats getActiveChats(String address) throws DataException; + public ActiveChats getActiveChats(String address, Encoding encoding) throws DataException; } diff --git a/src/main/java/org/qortal/repository/GroupRepository.java b/src/main/java/org/qortal/repository/GroupRepository.java index bcee7d25..94c97992 100644 --- a/src/main/java/org/qortal/repository/GroupRepository.java +++ b/src/main/java/org/qortal/repository/GroupRepository.java @@ -131,7 +131,14 @@ public interface GroupRepository { public GroupBanData getBan(int groupId, String member) throws DataException; - public boolean banExists(int groupId, String offender) throws DataException; + /** + * IMPORTANT: when using banExists() as part of validation, the timestamp must be that of the transaction that + * is calling banExists() as part of its validation. It must NOT be the current time, unless this is being + * called outside of validation, as part of an on demand check for a ban existing (such as via an API call). + * This is because we need to evaluate a ban's status based on the time of the subsequent transaction, as + * validation will not occur at a fixed time for every node. For some, it could be months into the future. + */ + public boolean banExists(int groupId, String offender, long timestamp) throws DataException; public List getGroupBans(int groupId, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/NameRepository.java b/src/main/java/org/qortal/repository/NameRepository.java index d6c0f33e..52a43a18 100644 --- a/src/main/java/org/qortal/repository/NameRepository.java +++ b/src/main/java/org/qortal/repository/NameRepository.java @@ -14,10 +14,12 @@ public interface NameRepository { public boolean reducedNameExists(String reducedName) throws DataException; - public List getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException; + + public List getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException; public default List getAllNames() throws DataException { - return getAllNames(null, null, null); + return getAllNames(null, null, null, null); } public List getNamesForSale(Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 0d9325b9..66b1d23e 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -2,14 +2,23 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.block.Block; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.ATTransactionData; +import org.qortal.data.transaction.TransactionData; import org.qortal.gui.SplashFrame; -import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; -import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; -import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; +import org.qortal.transaction.Transaction; +import org.qortal.transform.block.BlockTransformation; import java.sql.SQLException; +import java.util.*; import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import static org.qortal.transaction.Transaction.TransactionType.AT; public abstract class RepositoryManager { private static final Logger LOGGER = LogManager.getLogger(RepositoryManager.class); @@ -61,60 +70,162 @@ public abstract class RepositoryManager { } } - public static boolean archive(Repository repository) { - if (Settings.getInstance().isLite()) { - // Lite nodes have no blockchain + public static boolean needsTransactionSequenceRebuild(Repository repository) throws DataException { + // Check if we have any transactions without a block_sequence + List testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( + null, Arrays.asList("block_height IS NOT NULL AND block_sequence IS NULL"), new ArrayList<>(), 100); + if (testSignatures.isEmpty()) { + // block_sequence intact, so assume complete return false; } - // Bulk archive the database the first time we use archive mode - if (Settings.getInstance().isArchiveEnabled()) { - if (RepositoryManager.canArchiveOrPrune()) { - try { - return HSQLDBDatabaseArchiving.buildBlockArchive(repository, BlockArchiveWriter.DEFAULT_FILE_SIZE_TARGET); - - } catch (DataException e) { - LOGGER.info("Unable to build block archive. The database may have been left in an inconsistent state."); - } - } - else { - LOGGER.info("Unable to build block archive due to missing ATStatesHeightIndex. Bootstrapping is recommended."); - LOGGER.info("To bootstrap, stop the core and delete the db folder, then start the core again."); - SplashFrame.getInstance().updateStatus("Missing index. Bootstrapping is recommended."); - } - } - return false; + return true; } - public static boolean prune(Repository repository) { + public static boolean rebuildTransactionSequences(Repository repository) throws DataException { if (Settings.getInstance().isLite()) { // Lite nodes have no blockchain return false; } + if (Settings.getInstance().isTopOnly()) { + // topOnly nodes are unable to perform this reindex, and so are temporarily unsupported + throw new DataException("topOnly nodes are now unsupported, as they are missing data required for a db reshape"); + } - // Bulk prune the database the first time we use top-only or block archive mode - if (Settings.getInstance().isTopOnly() || - Settings.getInstance().isArchiveEnabled()) { - if (RepositoryManager.canArchiveOrPrune()) { - try { - boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates((HSQLDBRepository) repository); - boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks((HSQLDBRepository) repository); + try { + // Check if we have any unpopulated block_sequence values for the first 1000 blocks + if (!needsTransactionSequenceRebuild(repository)) { + // block_sequence already populated for the first 1000 blocks, so assume complete. + // We avoid checkpointing and prevent the node from starting up in the case of a rebuild failure, so + // we shouldn't ever be left in a partially rebuilt state. + return false; + } - // Perform repository maintenance to shrink the db size down - if (prunedATStates && prunedBlocks) { - HSQLDBDatabasePruning.performMaintenance(repository); - return true; + LOGGER.info("Rebuilding transaction sequences - this will take a while..."); + + SplashFrame.getInstance().updateStatus("Rebuilding transactions - please wait..."); + + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + int totalTransactionCount = 0; + + for (int height = 1; height <= blockchainHeight; ++height) { + List inputTransactions = new ArrayList<>(); + + // Fetch block and transactions + BlockData blockData = repository.getBlockRepository().fromHeight(height); + boolean loadedFromArchive = false; + if (blockData == null) { + // Get (non-AT) transactions from the archive + BlockTransformation blockTransformation = BlockArchiveReader.getInstance().fetchBlockAtHeight(height); + blockData = blockTransformation.getBlockData(); + inputTransactions = blockTransformation.getTransactions(); // This doesn't include AT transactions + loadedFromArchive = true; + } + else { + // Get transactions from db + Block block = new Block(repository, blockData); + for (Transaction transaction : block.getTransactions()) { + inputTransactions.add(transaction.getTransactionData()); + } + } + + if (blockData == null) { + throw new DataException("Missing block data"); + } + + List transactions = new ArrayList<>(); + + if (loadedFromArchive) { + List transactionDataList = new ArrayList<>(blockData.getTransactionCount()); + // Fetch any AT transactions in this block + List atSignatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height); + for (byte[] s : atSignatures) { + TransactionData transactionData = repository.getTransactionRepository().fromSignature(s); + if (transactionData.getType() == AT) { + transactionDataList.add(transactionData); + } } - } catch (SQLException | DataException e) { - LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); + List atTransactions = new ArrayList<>(); + for (TransactionData transactionData : transactionDataList) { + ATTransactionData atTransactionData = (ATTransactionData) transactionData; + atTransactions.add(atTransactionData); + } + + // Create sorted list of ATs by creation time + List ats = new ArrayList<>(); + + for (ATTransactionData atTransactionData : atTransactions) { + ATData atData = repository.getATRepository().fromATAddress(atTransactionData.getATAddress()); + boolean hasExistingEntry = ats.stream().anyMatch(a -> Objects.equals(a.getATAddress(), atTransactionData.getATAddress())); + if (!hasExistingEntry) { + ats.add(atData); + } + } + + // Sort list of ATs by creation date + ats.sort(Comparator.comparingLong(ATData::getCreation)); + + // Loop through unique ATs + for (ATData atData : ats) { + List thisAtTransactions = atTransactions.stream() + .filter(t -> Objects.equals(t.getATAddress(), atData.getATAddress())) + .collect(Collectors.toList()); + + int count = thisAtTransactions.size(); + + if (count == 1) { + ATTransactionData atTransactionData = thisAtTransactions.get(0); + transactions.add(atTransactionData); + } + else if (count == 2) { + String atCreatorAddress = Crypto.toAddress(atData.getCreatorPublicKey()); + + ATTransactionData atTransactionData1 = thisAtTransactions.stream() + .filter(t -> !Objects.equals(t.getRecipient(), atCreatorAddress)) + .findFirst().orElse(null); + transactions.add(atTransactionData1); + + ATTransactionData atTransactionData2 = thisAtTransactions.stream() + .filter(t -> Objects.equals(t.getRecipient(), atCreatorAddress)) + .findFirst().orElse(null); + transactions.add(atTransactionData2); + } + else if (count > 2) { + LOGGER.info("Error: AT has more than 2 output transactions"); + } + } } + + // Add all the regular transactions now that AT transactions have been handled + transactions.addAll(inputTransactions); + totalTransactionCount += transactions.size(); + + // Loop through and update sequences + for (int sequence = 0; sequence < transactions.size(); ++sequence) { + TransactionData transactionData = transactions.get(sequence); + + // Update transaction's sequence in repository + repository.getTransactionRepository().updateBlockSequence(transactionData.getSignature(), sequence); + } + + if (height % 10000 == 0) { + LOGGER.info("Rebuilt sequences for {} blocks (total transactions: {})", height, totalTransactionCount); + } + + repository.saveChanges(); } - else { - LOGGER.info("Unable to prune blocks due to missing ATStatesHeightIndex. Bootstrapping is recommended."); - } + + LOGGER.info("Completed rebuild of transaction sequences."); + return true; + } + catch (DataException e) { + LOGGER.info("Unable to rebuild transaction sequences: {}. The database may have been left in an inconsistent state.", e.getMessage()); + + // Throw an exception so that the node startup is halted, allowing for a retry next time. + repository.discardChanges(); + throw new DataException("Rebuild of transaction sequences failed."); } - return false; } public static void setRequestedCheckpoint(Boolean quick) { diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 4fb9bb12..41986cad 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -125,6 +125,23 @@ public interface TransactionRepository { public List getSignaturesMatchingCustomCriteria(TransactionType txType, List whereClauses, List bindParams) throws DataException; + /** + * Returns signatures for transactions that match search criteria, with optional limit. + *

+ * Alternate version that allows for custom where clauses and bind params. + * Only use for very specific use cases, such as the names integrity check. + * Not advised to be used otherwise, given that it could be possible for + * unsanitized inputs to be passed in if not careful. + * + * @param txType + * @param whereClauses + * @param bindParams + * @return + * @throws DataException + */ + public List getSignaturesMatchingCustomCriteria(TransactionType txType, List whereClauses, + List bindParams, Integer limit) throws DataException; + /** * Returns signature for latest auto-update transaction. *

@@ -179,6 +196,15 @@ public interface TransactionRepository { public List getAssetTransfers(long assetId, String address, Integer limit, Integer offset, Boolean reverse) throws DataException; + /** + * Returns list of reward share transaction creators, excluding self shares. + * This uses confirmed transactions only. + * + * @return + * @throws DataException + */ + public List getConfirmedRewardShareCreatorsExcludingSelfShares() throws DataException; + /** * Returns list of transactions pending approval, with optional txGgroupId filtering. *

@@ -288,7 +314,7 @@ public interface TransactionRepository { * @return list of transactions, or empty if none. * @throws DataException */ - public List getUnconfirmedTransactions(EnumSet excludedTxTypes) throws DataException; + public List getUnconfirmedTransactions(EnumSet excludedTxTypes, Integer limit) throws DataException; /** * Remove transaction from unconfirmed transactions pile. @@ -300,6 +326,8 @@ public interface TransactionRepository { public void updateBlockHeight(byte[] signature, Integer height) throws DataException; + public void updateBlockSequence(byte[] signature, Integer sequence) throws DataException; + public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException; /** diff --git a/src/main/java/org/qortal/repository/VotingRepository.java b/src/main/java/org/qortal/repository/VotingRepository.java index 28a9f6c7..b0e2954c 100644 --- a/src/main/java/org/qortal/repository/VotingRepository.java +++ b/src/main/java/org/qortal/repository/VotingRepository.java @@ -9,6 +9,8 @@ public interface VotingRepository { // Polls + public List getAllPolls(Integer limit, Integer offset, Boolean reverse) throws DataException; + public PollData fromPollName(String pollName) throws DataException; public boolean pollExists(String pollName) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 04823925..33817309 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -296,10 +296,9 @@ public class HSQLDBATRepository implements ATRepository { @Override public Integer getATCreationBlockHeight(String atAddress) throws DataException { - String sql = "SELECT height " + String sql = "SELECT block_height " + "FROM DeployATTransactions " - + "JOIN BlockTransactions ON transaction_signature = signature " - + "JOIN Blocks ON Blocks.signature = block_signature " + + "JOIN Transactions USING (signature) " + "WHERE AT_address = ? " + "LIMIT 1"; @@ -603,7 +602,7 @@ public class HSQLDBATRepository implements ATRepository { @Override - public void rebuildLatestAtStates() throws DataException { + public void rebuildLatestAtStates(int maxHeight) throws DataException { // latestATStatesLock is to prevent concurrent updates on LatestATStates // that could result in one process using a partial or empty dataset // because it was in the process of being rebuilt by another thread @@ -624,11 +623,12 @@ public class HSQLDBATRepository implements ATRepository { + "CROSS JOIN LATERAL(" + "SELECT height FROM ATStates " + "WHERE ATStates.AT_address = ATs.AT_address " + + "AND height <= ?" + "ORDER BY AT_address DESC, height DESC LIMIT 1" + ") " + ")"; try { - this.repository.executeCheckedUpdate(insertSql); + this.repository.executeCheckedUpdate(insertSql, maxHeight); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to populate temporary latest AT states cache in repository", e); @@ -876,18 +876,17 @@ public class HSQLDBATRepository implements ATRepository { public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException { // We only need to search for a subset of transaction types: MESSAGE, PAYMENT or AT - String sql = "SELECT height, sequence, Transactions.signature " + String sql = "SELECT block_height, block_sequence, Transactions.signature " + "FROM (" + "SELECT signature FROM PaymentTransactions WHERE recipient = ? " + "UNION " + "SELECT signature FROM MessageTransactions WHERE recipient = ? " + "UNION " + "SELECT signature FROM ATTransactions WHERE recipient = ?" - + ") AS Transactions " - + "JOIN BlockTransactions ON BlockTransactions.transaction_signature = Transactions.signature " - + "JOIN Blocks ON Blocks.signature = BlockTransactions.block_signature " - + "WHERE (height > ? OR (height = ? AND sequence > ?)) " - + "ORDER BY height ASC, sequence ASC " + + ") AS SelectedTransactions " + + "JOIN Transactions USING (signature)" + + "WHERE (block_height > ? OR (block_height = ? AND block_sequence > ?)) " + + "ORDER BY block_height ASC, block_sequence ASC " + "LIMIT 1"; Object[] bindParams = new Object[] { recipient, recipient, recipient, height, height, sequence }; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java index 9fdb0a3f..cb188502 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java @@ -6,15 +6,11 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.qortal.asset.Asset; -import org.qortal.data.account.AccountBalanceData; -import org.qortal.data.account.AccountData; -import org.qortal.data.account.EligibleQoraHolderData; -import org.qortal.data.account.MintingAccountData; -import org.qortal.data.account.QortFromQoraData; -import org.qortal.data.account.RewardShareData; +import org.qortal.data.account.*; import org.qortal.repository.AccountRepository; import org.qortal.repository.DataException; @@ -30,7 +26,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public AccountData getAccount(String address) throws DataException { - String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment FROM Accounts WHERE account = ?"; + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty FROM Accounts WHERE account = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, address)) { if (resultSet == null) @@ -43,8 +39,9 @@ public class HSQLDBAccountRepository implements AccountRepository { int level = resultSet.getInt(5); int blocksMinted = resultSet.getInt(6); int blocksMintedAdjustment = resultSet.getInt(7); + int blocksMintedPenalty = resultSet.getInt(8); - return new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment); + return new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty); } catch (SQLException e) { throw new DataException("Unable to fetch account info from repository", e); } @@ -52,7 +49,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public List getFlaggedAccounts(int mask) throws DataException { - String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, account FROM Accounts WHERE BITAND(flags, ?) != 0"; + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty, account FROM Accounts WHERE BITAND(flags, ?) != 0"; List accounts = new ArrayList<>(); @@ -68,9 +65,10 @@ public class HSQLDBAccountRepository implements AccountRepository { int level = resultSet.getInt(5); int blocksMinted = resultSet.getInt(6); int blocksMintedAdjustment = resultSet.getInt(7); - String address = resultSet.getString(8); + int blocksMintedPenalty = resultSet.getInt(8); + String address = resultSet.getString(9); - accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment)); + accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty)); } while (resultSet.next()); return accounts; @@ -79,6 +77,36 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public List getPenaltyAccounts() throws DataException { + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty, account FROM Accounts WHERE blocks_minted_penalty != 0"; + + List accounts = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return accounts; + + do { + byte[] reference = resultSet.getBytes(1); + byte[] publicKey = resultSet.getBytes(2); + int defaultGroupId = resultSet.getInt(3); + int flags = resultSet.getInt(4); + int level = resultSet.getInt(5); + int blocksMinted = resultSet.getInt(6); + int blocksMintedAdjustment = resultSet.getInt(7); + int blocksMintedPenalty = resultSet.getInt(8); + String address = resultSet.getString(9); + + accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty)); + } while (resultSet.next()); + + return accounts; + } catch (SQLException e) { + throw new DataException("Unable to fetch penalty accounts from repository", e); + } + } + @Override public byte[] getLastReference(String address) throws DataException { String sql = "SELECT reference FROM Accounts WHERE account = ?"; @@ -298,6 +326,39 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public Integer getBlocksMintedPenaltyCount(String address) throws DataException { + String sql = "SELECT blocks_minted_penalty FROM Accounts WHERE account = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, address)) { + if (resultSet == null) + return null; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch account's block minted penalty count from repository", e); + } + } + public void updateBlocksMintedPenalties(Set accountPenalties) throws DataException { + // Nothing to do? + if (accountPenalties == null || accountPenalties.isEmpty()) + return; + + // Map balance changes into SQL bind params, filtering out no-op changes + List updateBlocksMintedPenaltyParams = accountPenalties.stream() + .map(accountPenalty -> new Object[] { accountPenalty.getAddress(), accountPenalty.getBlocksMintedPenalty(), accountPenalty.getBlocksMintedPenalty() }) + .collect(Collectors.toList()); + + // Perform actual balance changes + String sql = "INSERT INTO Accounts (account, blocks_minted_penalty) VALUES (?, ?) " + + "ON DUPLICATE KEY UPDATE blocks_minted_penalty = blocks_minted_penalty + ?"; + try { + this.repository.executeCheckedBatchUpdate(sql, updateBlocksMintedPenaltyParams); + } catch (SQLException e) { + throw new DataException("Unable to set blocks minted penalties in repository", e); + } + } + @Override public void delete(String address) throws DataException { // NOTE: Account balances are deleted automatically by the database thanks to "ON DELETE CASCADE" in AccountBalances' FOREIGN KEY diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index c21dd038..87841ca9 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -5,9 +5,7 @@ import org.apache.logging.log4j.Logger; import org.bouncycastle.util.Longs; import org.qortal.arbitrary.misc.Service; import org.qortal.data.arbitrary.ArbitraryResourceInfo; -import org.qortal.crypto.Crypto; import org.qortal.data.arbitrary.ArbitraryResourceNameInfo; -import org.qortal.data.network.ArbitraryPeerData; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.ArbitraryTransactionData.*; import org.qortal.data.transaction.BaseTransactionData; @@ -15,8 +13,10 @@ import org.qortal.data.transaction.TransactionData; import org.qortal.repository.ArbitraryRepository; import org.qortal.repository.DataException; import org.qortal.arbitrary.ArbitraryDataFile; +import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.Transaction.ApprovalStatus; import org.qortal.utils.Base58; +import org.qortal.utils.ListUtils; import java.sql.ResultSet; import java.sql.SQLException; @@ -27,8 +27,6 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { private static final Logger LOGGER = LogManager.getLogger(HSQLDBArbitraryRepository.class); - private static final int MAX_RAW_DATA_SIZE = 255; // size of VARBINARY - protected HSQLDBRepository repository; public HSQLDBArbitraryRepository(HSQLDBRepository repository) { @@ -55,13 +53,8 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { return true; } - // Load hashes - byte[] hash = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - // Load data file(s) - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); // Check if we already have the complete data file or all chunks if (arbitraryDataFile.allFilesExist()) { @@ -84,13 +77,8 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { return transactionData.getData(); } - // Load hashes - byte[] digest = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - // Load data file(s) - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); // If we have the complete data file, return it if (arbitraryDataFile.exists()) { @@ -105,6 +93,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { arbitraryDataFile.join(); // Verify that the combined hash matches the expected hash + byte[] digest = transactionData.getData(); if (!digest.equals(arbitraryDataFile.digest())) { LOGGER.info(String.format("Hash mismatch for transaction: %s", Base58.encode(signature))); return null; @@ -132,11 +121,11 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } // Trivial-sized payloads can remain in raw form - if (arbitraryTransactionData.getDataType() == DataType.RAW_DATA && arbitraryTransactionData.getData().length <= MAX_RAW_DATA_SIZE) { + if (arbitraryTransactionData.getDataType() == DataType.RAW_DATA && arbitraryTransactionData.getData().length <= ArbitraryTransaction.MAX_DATA_SIZE) { return; } - throw new IllegalStateException(String.format("Supplied data is larger than maximum size (%d bytes). Please use ArbitraryDataWriter.", MAX_RAW_DATA_SIZE)); + throw new IllegalStateException(String.format("Supplied data is larger than maximum size (%d bytes). Please use ArbitraryDataWriter.", ArbitraryTransaction.MAX_DATA_SIZE)); } @Override @@ -146,17 +135,11 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { return; } - // Load hashes - byte[] hash = arbitraryTransactionData.getData(); - byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); - // Load data file(s) - byte[] signature = arbitraryTransactionData.getSignature(); - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); - // Delete file and chunks - arbitraryDataFile.deleteAll(); + // Delete file, chunks, and metadata + arbitraryDataFile.deleteAll(true); } @Override @@ -202,7 +185,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { int version = resultSet.getInt(11); int nonce = resultSet.getInt(12); - Service serviceResult = Service.valueOf(resultSet.getInt(13)); + int serviceInt = resultSet.getInt(13); int size = resultSet.getInt(14); boolean isDataRaw = resultSet.getBoolean(15); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -216,7 +199,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, serviceResult, nonce, size, nameResult, identifierResult, method, secret, + version, serviceInt, nonce, size, nameResult, identifierResult, method, secret, compression, data, dataType, metadataHash, null); arbitraryTransactionData.add(transactionData); @@ -277,7 +260,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { int version = resultSet.getInt(11); int nonce = resultSet.getInt(12); - Service serviceResult = Service.valueOf(resultSet.getInt(13)); + int serviceInt = resultSet.getInt(13); int size = resultSet.getInt(14); boolean isDataRaw = resultSet.getBoolean(15); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -291,7 +274,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, serviceResult, nonce, size, nameResult, identifierResult, methodResult, secret, + version, serviceInt, nonce, size, nameResult, identifierResult, methodResult, secret, compression, data, dataType, metadataHash, null); return transactionData; @@ -302,7 +285,8 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { @Override public List getArbitraryResources(Service service, String identifier, List names, - boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException { + boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, + Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); @@ -337,6 +321,36 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { sql.append(")"); } + // Handle "followed only" + if (followedOnly != null && followedOnly) { + List followedNames = ListUtils.followedNames(); + if (followedNames != null && !followedNames.isEmpty()) { + sql.append(" AND name IN (?"); + bindParams.add(followedNames.get(0)); + + for (int i = 1; i < followedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(followedNames.get(i)); + } + sql.append(")"); + } + } + + // Handle "exclude blocked" + if (excludeBlocked != null && excludeBlocked) { + List blockedNames = ListUtils.blockedNames(); + if (blockedNames != null && !blockedNames.isEmpty()) { + sql.append(" AND name NOT IN (?"); + bindParams.add(blockedNames.get(0)); + + for (int i = 1; i < blockedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(blockedNames.get(i)); + } + sql.append(")"); + } + } + sql.append(" GROUP BY name, service, identifier ORDER BY name COLLATE SQL_TEXT_UCC_NO_PAD"); if (reverse != null && reverse) { @@ -378,37 +392,107 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } @Override - public List searchArbitraryResources(Service service, String query, - boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException { + public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, + List exactMatchNames, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, + Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); - // For now we are searching anywhere in the fields - // Note that this will bypass any indexes so may not scale well - // Longer term we probably want to copy resources to their own table anyway - String queryWildcard = String.format("%%%s%%", query.toLowerCase()); - - sql.append("SELECT name, service, identifier, MAX(size) AS max_size FROM ArbitraryTransactions WHERE 1=1"); + sql.append("SELECT name, service, identifier, MAX(size) AS max_size, MIN(created_when) AS date_created, MAX(created_when) AS date_updated " + + "FROM ArbitraryTransactions " + + "JOIN Transactions USING (signature) " + + "WHERE 1=1"); if (service != null) { sql.append(" AND service = "); sql.append(service.value); } - if (defaultResource) { - // Default resource requested - use NULL identifier and search name only - sql.append(" AND LCASE(name) LIKE ? AND identifier IS NULL"); - bindParams.add(queryWildcard); + // Handle general query matches + if (query != null) { + // Search anywhere in the fields, unless "prefixOnly" has been requested + // Note that without prefixOnly it will bypass any indexes so may not scale well + // Longer term we probably want to copy resources to their own table anyway + String queryWildcard = prefixOnly ? String.format("%s%%", query.toLowerCase()) : String.format("%%%s%%", query.toLowerCase()); + + if (defaultResource) { + // Default resource requested - use NULL identifier and search name only + sql.append(" AND LCASE(name) LIKE ? AND identifier IS NULL"); + bindParams.add(queryWildcard); + } else { + // Non-default resource requested + // In this case we search the identifier as well as the name + sql.append(" AND (LCASE(name) LIKE ? OR LCASE(identifier) LIKE ?)"); + bindParams.add(queryWildcard); + bindParams.add(queryWildcard); + } } - else { - // Non-default resource requested - // In this case we search the identifier as well as the name - sql.append(" AND (LCASE(name) LIKE ? OR LCASE(identifier) LIKE ?)"); - bindParams.add(queryWildcard); + + // Handle identifier matches + if (identifier != null) { + // Search anywhere in the identifier, unless "prefixOnly" has been requested + String queryWildcard = prefixOnly ? String.format("%s%%", identifier.toLowerCase()) : String.format("%%%s%%", identifier.toLowerCase()); + sql.append(" AND LCASE(identifier) LIKE ?"); bindParams.add(queryWildcard); } - sql.append(" GROUP BY name, service, identifier ORDER BY name COLLATE SQL_TEXT_UCC_NO_PAD"); + // Handle name searches + if (names != null && !names.isEmpty()) { + sql.append(" AND ("); + + for (int i = 0; i < names.size(); ++i) { + // Search anywhere in the name, unless "prefixOnly" has been requested + String queryWildcard = prefixOnly ? String.format("%s%%", names.get(i).toLowerCase()) : String.format("%%%s%%", names.get(i).toLowerCase()); + if (i > 0) sql.append(" OR "); + sql.append("LCASE(name) LIKE ?"); + bindParams.add(queryWildcard); + } + sql.append(")"); + } + + // Handle name exact matches + if (exactMatchNames != null && !exactMatchNames.isEmpty()) { + sql.append(" AND LCASE(name) IN (?"); + bindParams.add(exactMatchNames.get(0).toLowerCase()); + + for (int i = 1; i < exactMatchNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(exactMatchNames.get(i).toLowerCase()); + } + sql.append(")"); + } + + // Handle "followed only" + if (followedOnly != null && followedOnly) { + List followedNames = ListUtils.followedNames(); + if (followedNames != null && !followedNames.isEmpty()) { + sql.append(" AND LCASE(name) IN (?"); + bindParams.add(followedNames.get(0).toLowerCase()); + + for (int i = 1; i < followedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(followedNames.get(i).toLowerCase()); + } + sql.append(")"); + } + } + + // Handle "exclude blocked" + if (excludeBlocked != null && excludeBlocked) { + List blockedNames = ListUtils.blockedNames(); + if (blockedNames != null && !blockedNames.isEmpty()) { + sql.append(" AND LCASE(name) NOT IN (?"); + bindParams.add(blockedNames.get(0).toLowerCase()); + + for (int i = 1; i < blockedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(blockedNames.get(i).toLowerCase()); + } + sql.append(")"); + } + } + + sql.append(" GROUP BY name, service, identifier ORDER BY date_created"); if (reverse != null && reverse) { sql.append(" DESC"); @@ -427,6 +511,8 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { Service serviceResult = Service.valueOf(resultSet.getInt(2)); String identifierResult = resultSet.getString(3); Integer sizeResult = resultSet.getInt(4); + long dateCreated = resultSet.getLong(5); + long dateUpdated = resultSet.getLong(6); // We should filter out resources without names if (nameResult == null) { @@ -438,6 +524,8 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { arbitraryResourceInfo.service = serviceResult; arbitraryResourceInfo.identifier = identifierResult; arbitraryResourceInfo.size = Longs.valueOf(sizeResult); + arbitraryResourceInfo.created = dateCreated; + arbitraryResourceInfo.updated = dateUpdated; arbitraryResources.add(arbitraryResourceInfo); } while (resultSet.next()); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index 2f570686..9e310e78 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -14,6 +14,8 @@ import org.qortal.repository.ChatRepository; import org.qortal.repository.DataException; import org.qortal.transaction.Transaction.TransactionType; +import static org.qortal.data.chat.ChatMessage.Encoding; + public class HSQLDBChatRepository implements ChatRepository { protected HSQLDBRepository repository; @@ -24,8 +26,8 @@ public class HSQLDBChatRepository implements ChatRepository { @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, - List involving, Integer limit, Integer offset, Boolean reverse) - throws DataException { + byte[] chatReferenceBytes, Boolean hasChatReference, List involving, String senderAddress, + Encoding encoding, Integer limit, Integer offset, Boolean reverse) throws DataException { // Check args meet expectations if ((txGroupId != null && involving != null && !involving.isEmpty()) || (txGroupId == null && (involving == null || involving.size() != 2))) @@ -35,7 +37,7 @@ public class HSQLDBChatRepository implements ChatRepository { sql.append("SELECT created_when, tx_group_id, Transactions.reference, creator, " + "sender, SenderNames.name, recipient, RecipientNames.name, " - + "data, is_text, is_encrypted, signature " + + "chat_reference, data, is_text, is_encrypted, signature " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -62,6 +64,23 @@ public class HSQLDBChatRepository implements ChatRepository { bindParams.add(referenceBytes); } + if (chatReferenceBytes != null) { + whereClauses.add("chat_reference = ?"); + bindParams.add(chatReferenceBytes); + } + + if (hasChatReference != null && hasChatReference == true) { + whereClauses.add("chat_reference IS NOT NULL"); + } + else if (hasChatReference != null && hasChatReference == false) { + whereClauses.add("chat_reference IS NULL"); + } + + if (senderAddress != null) { + whereClauses.add("sender = ?"); + bindParams.add(senderAddress); + } + if (txGroupId != null) { whereClauses.add("tx_group_id = " + txGroupId); // int safe to use literally whereClauses.add("recipient IS NULL"); @@ -103,13 +122,14 @@ public class HSQLDBChatRepository implements ChatRepository { String senderName = resultSet.getString(6); String recipient = resultSet.getString(7); String recipientName = resultSet.getString(8); - byte[] data = resultSet.getBytes(9); - boolean isText = resultSet.getBoolean(10); - boolean isEncrypted = resultSet.getBoolean(11); - byte[] signature = resultSet.getBytes(12); + byte[] chatReference = resultSet.getBytes(9); + byte[] data = resultSet.getBytes(10); + boolean isText = resultSet.getBoolean(11); + boolean isEncrypted = resultSet.getBoolean(12); + byte[] signature = resultSet.getBytes(13); ChatMessage chatMessage = new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, encoding, data, isText, isEncrypted, signature); chatMessages.add(chatMessage); } while (resultSet.next()); @@ -121,7 +141,7 @@ public class HSQLDBChatRepository implements ChatRepository { } @Override - public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) throws DataException { + public ChatMessage toChatMessage(ChatTransactionData chatTransactionData, Encoding encoding) throws DataException { String sql = "SELECT SenderNames.name, RecipientNames.name " + "FROM ChatTransactions " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -141,33 +161,35 @@ public class HSQLDBChatRepository implements ChatRepository { byte[] senderPublicKey = chatTransactionData.getSenderPublicKey(); String sender = chatTransactionData.getSender(); String recipient = chatTransactionData.getRecipient(); + byte[] chatReference = chatTransactionData.getChatReference(); byte[] data = chatTransactionData.getData(); boolean isText = chatTransactionData.getIsText(); boolean isEncrypted = chatTransactionData.getIsEncrypted(); byte[] signature = chatTransactionData.getSignature(); return new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, encoding, data, + isText, isEncrypted, signature); } catch (SQLException e) { throw new DataException("Unable to fetch convert chat transaction from repository", e); } } @Override - public ActiveChats getActiveChats(String address) throws DataException { - List groupChats = getActiveGroupChats(address); + public ActiveChats getActiveChats(String address, Encoding encoding) throws DataException { + List groupChats = getActiveGroupChats(address, encoding); List directChats = getActiveDirectChats(address); return new ActiveChats(groupChats, directChats); } - private List getActiveGroupChats(String address) throws DataException { + private List getActiveGroupChats(String address, Encoding encoding) throws DataException { // Find groups where address is a member and potential latest message details - String groupsSql = "SELECT group_id, group_name, latest_timestamp, sender, sender_name " + String groupsSql = "SELECT group_id, group_name, latest_timestamp, sender, sender_name, signature, data " + "FROM GroupMembers " + "JOIN Groups USING (group_id) " + "LEFT OUTER JOIN LATERAL(" - + "SELECT created_when AS latest_timestamp, sender, name AS sender_name " + + "SELECT created_when AS latest_timestamp, sender, name AS sender_name, signature, data " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -191,8 +213,10 @@ public class HSQLDBChatRepository implements ChatRepository { String sender = resultSet.getString(4); String senderName = resultSet.getString(5); + byte[] signature = resultSet.getBytes(6); + byte[] data = resultSet.getBytes(7); - GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName); + GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName, signature, encoding, data); groupChats.add(groupChat); } while (resultSet.next()); } @@ -201,7 +225,7 @@ public class HSQLDBChatRepository implements ChatRepository { } // We need different SQL to handle group-less chat - String grouplessSql = "SELECT created_when, sender, SenderNames.name " + String grouplessSql = "SELECT created_when, sender, SenderNames.name, signature, data " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -214,15 +238,19 @@ public class HSQLDBChatRepository implements ChatRepository { Long timestamp = null; String sender = null; String senderName = null; + byte[] signature = null; + byte[] data = null; if (resultSet != null) { // We found a recipient-less, group-less CHAT message, so report its details timestamp = resultSet.getLong(1); sender = resultSet.getString(2); senderName = resultSet.getString(3); + signature = resultSet.getBytes(4); + data = resultSet.getBytes(5); } - GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName); + GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName, signature, encoding, data); groupChats.add(groupChat); } catch (SQLException e) { throw new DataException("Unable to fetch active group chats from repository", e); @@ -277,4 +305,4 @@ public class HSQLDBChatRepository implements ChatRepository { return directChats; } -} +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java deleted file mode 100644 index 90022b00..00000000 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.qortal.repository.hsqldb; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.qortal.controller.Controller; -import org.qortal.gui.SplashFrame; -import org.qortal.repository.BlockArchiveWriter; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.transform.TransformationException; - -import java.io.IOException; - -/** - * - * When switching to an archiving node, we need to archive most of the database contents. - * This involves copying its data into flat files. - * If we do this entirely as a background process, it is very slow and can interfere with syncing. - * However, if we take the approach of doing this in bulk, before starting up the rest of the - * processes, this makes it much faster and less invasive. - * - * From that point, the original background archiving process will run, but can be dialled right down - * so not to interfere with syncing. - * - */ - - -public class HSQLDBDatabaseArchiving { - - private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabaseArchiving.class); - - - public static boolean buildBlockArchive(Repository repository, long fileSizeTarget) throws DataException { - - // Only build the archive if we haven't already got one that is up to date - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (upToDate) { - // Already archived - return false; - } - - LOGGER.info("Building block archive - this process could take a while..."); - SplashFrame.getInstance().updateStatus("Building block archive..."); - - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - int startHeight = 0; - - while (!Controller.isStopping()) { - try { - BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); - writer.setFileSizeTarget(fileSizeTarget); - BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); - switch (result) { - case OK: - // Increment block archive height - startHeight = writer.getLastWrittenHeight() + 1; - repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); - repository.saveChanges(); - break; - - case STOPPING: - return false; - - case NOT_ENOUGH_BLOCKS: - // We've reached the limit of the blocks we can archive - // Return from the whole method - return true; - - case BLOCK_NOT_FOUND: - // We tried to archive a block that didn't exist. This is a major failure and likely means - // that a bootstrap or re-sync is needed. Return rom the method - LOGGER.info("Error: block not found when building archive. If this error persists, " + - "a bootstrap or re-sync may be needed."); - return false; - } - - } catch (IOException | TransformationException | InterruptedException e) { - LOGGER.info("Caught exception when creating block cache", e); - return false; - } - } - - // If we got this far then something went wrong (most likely the app is stopping) - return false; - } - -} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java deleted file mode 100644 index 978ba25e..00000000 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ /dev/null @@ -1,332 +0,0 @@ -package org.qortal.repository.hsqldb; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.qortal.controller.Controller; -import org.qortal.data.block.BlockData; -import org.qortal.gui.SplashFrame; -import org.qortal.repository.BlockArchiveWriter; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.settings.Settings; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.concurrent.TimeoutException; - -/** - * - * When switching from a full node to a pruning node, we need to delete most of the database contents. - * If we do this entirely as a background process, it is very slow and can interfere with syncing. - * However, if we take the approach of transferring only the necessary rows to a new table and then - * deleting the original table, this makes the process much faster. It was taking several days to - * delete the AT states in the background, but only a couple of minutes to copy them to a new table. - * - * The trade off is that we have to go through a form of "reshape" when starting the app for the first - * time after enabling pruning mode. But given that this is an opt-in mode, I don't think it will be - * a problem. - * - * Once the pruning is complete, it automatically performs a CHECKPOINT DEFRAG in order to - * shrink the database file size down to a fraction of what it was before. - * - * From this point, the original background process will run, but can be dialled right down so not - * to interfere with syncing. - * - */ - - -public class HSQLDBDatabasePruning { - - private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabasePruning.class); - - - public static boolean pruneATStates(HSQLDBRepository repository) throws SQLException, DataException { - - // Only bulk prune AT states if we have never done so before - int pruneHeight = repository.getATRepository().getAtPruneHeight(); - if (pruneHeight > 0) { - // Already pruned AT states - return false; - } - - if (Settings.getInstance().isArchiveEnabled()) { - // Only proceed if we can see that the archiver has already finished - // This way, if the archiver failed for any reason, we can prune once it has had - // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (!upToDate) { - return false; - } - } - - LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + - "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); - SplashFrame.getInstance().updateStatus("Pruning database (takes up to 30 mins)..."); - - // Create new AT-states table to hold smaller dataset - repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); - repository.executeCheckedUpdate("CREATE TABLE ATStatesNew (" - + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " - + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " - + "PRIMARY KEY (AT_address, height), " - + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); - repository.executeCheckedUpdate("SET TABLE ATStatesNew NEW SPACE"); - repository.executeCheckedUpdate("CHECKPOINT"); - - // Add a height index - LOGGER.info("Adding index to AT states table..."); - repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesNewHeightIndex ON ATStatesNew (height)"); - repository.executeCheckedUpdate("CHECKPOINT"); - - - // Find our latest block - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); - return false; - } - - // Calculate some constants for later use - final int blockchainHeight = latestBlock.getHeight(); - int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - if (Settings.getInstance().isArchiveEnabled()) { - // Archive mode - don't prune anything that hasn't been archived yet - maximumBlockToTrim = Math.min(maximumBlockToTrim, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); - } - final int endHeight = blockchainHeight; - final int blockStep = 10000; - - - // It's essential that we rebuild the latest AT states here, as we are using this data in the next query. - // Failing to do this will result in important AT states being deleted, rendering the database unusable. - repository.getATRepository().rebuildLatestAtStates(); - - - // Loop through all the LatestATStates and copy them to the new table - LOGGER.info("Copying AT states..."); - for (int height = 0; height < endHeight; height += blockStep) { - final int batchEndHeight = height + blockStep - 1; - //LOGGER.info(String.format("Copying AT states between %d and %d...", height, batchEndHeight)); - - String sql = "SELECT height, AT_address FROM LatestATStates WHERE height BETWEEN ? AND ?"; - try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, batchEndHeight)) { - if (latestAtStatesResultSet != null) { - do { - int latestAtHeight = latestAtStatesResultSet.getInt(1); - String latestAtAddress = latestAtStatesResultSet.getString(2); - - // Copy this latest ATState to the new table - //LOGGER.info(String.format("Copying AT %s at height %d...", latestAtAddress, latestAtHeight)); - try { - String updateSql = "INSERT INTO ATStatesNew (" - + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " - + "FROM ATStates " - + "WHERE height = ? AND AT_address = ?)"; - repository.executeCheckedUpdate(updateSql, latestAtHeight, latestAtAddress); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to copy ATStates", e); - } - - // If this batch includes blocks after the maximum block to trim, we will need to copy - // each of its AT states above maximumBlockToTrim as they are considered "recent". We - // need to do this for _all_ AT states in these blocks, regardless of their latest state. - if (batchEndHeight >= maximumBlockToTrim) { - // Now copy this AT's states for each recent block they are present in - for (int i = maximumBlockToTrim; i < endHeight; i++) { - if (latestAtHeight < i) { - // This AT finished before this block so there is nothing to copy - continue; - } - - //LOGGER.info(String.format("Copying recent AT %s at height %d...", latestAtAddress, i)); - try { - // Copy each LatestATState to the new table - String updateSql = "INSERT IGNORE INTO ATStatesNew (" - + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " - + "FROM ATStates " - + "WHERE height = ? AND AT_address = ?)"; - repository.executeCheckedUpdate(updateSql, i, latestAtAddress); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to copy ATStates", e); - } - } - } - repository.saveChanges(); - - } while (latestAtStatesResultSet.next()); - } - } catch (SQLException e) { - throw new DataException("Unable to copy AT states", e); - } - } - - - // Finally, drop the original table and rename - LOGGER.info("Deleting old AT states..."); - repository.executeCheckedUpdate("DROP TABLE ATStates"); - repository.executeCheckedUpdate("ALTER TABLE ATStatesNew RENAME TO ATStates"); - repository.executeCheckedUpdate("ALTER INDEX ATStatesNewHeightIndex RENAME TO ATStatesHeightIndex"); - repository.executeCheckedUpdate("CHECKPOINT"); - - // Update the prune height - int nextPruneHeight = maximumBlockToTrim + 1; - repository.getATRepository().setAtPruneHeight(nextPruneHeight); - repository.saveChanges(); - - repository.executeCheckedUpdate("CHECKPOINT"); - - // Now prune/trim the ATStatesData, as this currently goes back over a month - return HSQLDBDatabasePruning.pruneATStateData(repository); - } - - /* - * Bulk prune ATStatesData to catch up with the now pruned ATStates table - * This uses the existing AT States trimming code but with a much higher end block - */ - private static boolean pruneATStateData(Repository repository) throws DataException { - - if (Settings.getInstance().isArchiveEnabled()) { - // Don't prune ATStatesData in archive mode - return true; - } - - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); - return false; - } - final int blockchainHeight = latestBlock.getHeight(); - int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - // ATStateData is already trimmed - so carry on from where we left off in the past - int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); - - LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); - - while (pruneStartHeight < upperPrunableHeight) { - // Prune all AT state data up until our latest minus pruneBlockLimit (or our archive height) - - if (Controller.isStopping()) { - return false; - } - - // Override batch size in the settings because this is a one-off process - final int batchSize = 1000; - final int rowLimitPerBatch = 50000; - int upperBatchHeight = pruneStartHeight + batchSize; - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - - LOGGER.trace(String.format("Pruning AT states data between %d and %d...", pruneStartHeight, upperPruneHeight)); - - int numATStatesPruned = repository.getATRepository().trimAtStates(pruneStartHeight, upperPruneHeight, rowLimitPerBatch); - repository.saveChanges(); - - if (numATStatesPruned > 0) { - LOGGER.trace(String.format("Pruned %d AT states data rows between blocks %d and %d", - numATStatesPruned, pruneStartHeight, upperPruneHeight)); - } else { - repository.getATRepository().setAtTrimHeight(upperBatchHeight); - // No need to rebuild the latest AT states as we aren't currently synchronizing - repository.saveChanges(); - LOGGER.debug(String.format("Bumping AT states trim height to %d", upperBatchHeight)); - - // Can we move onto next batch? - if (upperPrunableHeight > upperBatchHeight) { - pruneStartHeight = upperBatchHeight; - } - else { - // We've finished pruning - break; - } - } - } - - return true; - } - - public static boolean pruneBlocks(Repository repository) throws SQLException, DataException { - - // Only bulk prune AT states if we have never done so before - int pruneHeight = repository.getBlockRepository().getBlockPruneHeight(); - if (pruneHeight > 0) { - // Already pruned blocks - return false; - } - - if (Settings.getInstance().isArchiveEnabled()) { - // Only proceed if we can see that the archiver has already finished - // This way, if the archiver failed for any reason, we can prune once it has had - // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (!upToDate) { - return false; - } - } - - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); - return false; - } - final int blockchainHeight = latestBlock.getHeight(); - int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - int pruneStartHeight = 0; - - if (Settings.getInstance().isArchiveEnabled()) { - // Archive mode - don't prune anything that hasn't been archived yet - upperPrunableHeight = Math.min(upperPrunableHeight, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); - } - - LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); - - while (pruneStartHeight < upperPrunableHeight) { - // Prune all blocks up until our latest minus pruneBlockLimit - - int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - - LOGGER.info(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); - - int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); - repository.saveChanges(); - - if (numBlocksPruned > 0) { - LOGGER.info(String.format("Pruned %d block%s between %d and %d", - numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), - pruneStartHeight, upperPruneHeight)); - } else { - final int nextPruneHeight = upperPruneHeight + 1; - repository.getBlockRepository().setBlockPruneHeight(nextPruneHeight); - repository.saveChanges(); - LOGGER.debug(String.format("Bumping block base prune height to %d", nextPruneHeight)); - - // Can we move onto next batch? - if (upperPrunableHeight > nextPruneHeight) { - pruneStartHeight = nextPruneHeight; - } - else { - // We've finished pruning - break; - } - } - } - - return true; - } - - public static void performMaintenance(Repository repository) throws SQLException, DataException { - try { - SplashFrame.getInstance().updateStatus("Performing maintenance..."); - - // Timeout if the database isn't ready for backing up after 5 minutes - // Nothing else should be using the db at this point, so a timeout shouldn't happen - long timeout = 5 * 60 * 1000L; - repository.performPeriodicMaintenance(timeout); - - } catch (TimeoutException e) { - LOGGER.info("Attempt to perform maintenance failed due to timeout: {}", e.getMessage()); - } - } - -} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 1174f5c8..cd2b30fa 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -975,6 +975,35 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN receiving_account_info SET DATA TYPE VARBINARY(128)"); break; + case 44: + // Add blocks minted penalty + stmt.execute("ALTER TABLE Accounts ADD blocks_minted_penalty INTEGER NOT NULL DEFAULT 0"); + break; + + case 45: + // Add a chat reference, to allow one message to reference another, and for this to be easily + // searchable. Null values are allowed as most transactions won't have a reference. + stmt.execute("ALTER TABLE ChatTransactions ADD chat_reference Signature"); + // For finding chat messages by reference + stmt.execute("CREATE INDEX ChatTransactionsChatReferenceIndex ON ChatTransactions (chat_reference)"); + break; + + case 46: + // We need to track the sale price when canceling a name sale, so it can be put back when orphaned + stmt.execute("ALTER TABLE CancelSellNameTransactions ADD sale_price QortalAmount"); + break; + + case 47: + // Add `block_sequence` to the Transaction table, as the BlockTransactions table is pruned for + // older blocks and therefore the sequence becomes unavailable + LOGGER.info("Reshaping Transactions table - this can take a while..."); + stmt.execute("ALTER TABLE Transactions ADD block_sequence INTEGER"); + + // For finding transactions by height and sequence + LOGGER.info("Adding index to Transactions table - this can take a while..."); + stmt.execute("CREATE INDEX TransactionHeightSequenceIndex on Transactions (block_height, block_sequence)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java index 91db22f1..b1cd40a0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java @@ -777,9 +777,9 @@ public class HSQLDBGroupRepository implements GroupRepository { } @Override - public boolean banExists(int groupId, String offender) throws DataException { + public boolean banExists(int groupId, String offender, long timestamp) throws DataException { try { - return this.repository.exists("GroupBans", "group_id = ? AND offender = ?", groupId, offender); + return this.repository.exists("GroupBans", "group_id = ? AND offender = ? AND (expires_when IS NULL OR expires_when > ?)", groupId, offender, timestamp); } catch (SQLException e) { throw new DataException("Unable to check for group ban in repository", e); } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java index f00c79fc..f31c5cd8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java @@ -28,7 +28,6 @@ public class HSQLDBMessageRepository implements MessageRepository { StringBuilder sql = new StringBuilder(1024); sql.append("SELECT signature from MessageTransactions " + "JOIN Transactions USING (signature) " - + "JOIN BlockTransactions ON transaction_signature = signature " + "WHERE "); List whereClauses = new ArrayList<>(); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java index 3a3574ef..2fefcf8b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java @@ -103,12 +103,18 @@ public class HSQLDBNameRepository implements NameRepository { } } - @Override - public List getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException { - StringBuilder sql = new StringBuilder(256); + public List searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(512); + List bindParams = new ArrayList<>(); sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, " - + "is_for_sale, sale_price, reference, creation_group_id FROM Names ORDER BY name"); + + "is_for_sale, sale_price, reference, creation_group_id FROM Names " + + "WHERE LCASE(name) LIKE ? ORDER BY name"); + + // Search anywhere in the name, unless "prefixOnly" has been requested + // Note that without prefixOnly it will bypass any indexes + String queryWildcard = prefixOnly ? String.format("%s%%", query.toLowerCase()) : String.format("%%%s%%", query.toLowerCase()); + bindParams.add(queryWildcard); if (reverse != null && reverse) sql.append(" DESC"); @@ -117,7 +123,64 @@ public class HSQLDBNameRepository implements NameRepository { List names = new ArrayList<>(); - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return names; + + do { + String name = resultSet.getString(1); + String reducedName = resultSet.getString(2); + String owner = resultSet.getString(3); + String data = resultSet.getString(4); + long registered = resultSet.getLong(5); + + // Special handling for possibly-NULL "updated" column + Long updated = resultSet.getLong(6); + if (updated == 0 && resultSet.wasNull()) + updated = null; + + boolean isForSale = resultSet.getBoolean(7); + + Long salePrice = resultSet.getLong(8); + if (salePrice == 0 && resultSet.wasNull()) + salePrice = null; + + byte[] reference = resultSet.getBytes(9); + int creationGroupId = resultSet.getInt(10); + + names.add(new NameData(name, reducedName, owner, data, registered, updated, isForSale, salePrice, reference, creationGroupId)); + } while (resultSet.next()); + + return names; + } catch (SQLException e) { + throw new DataException("Unable to search names in repository", e); + } + } + + @Override + public List getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(256); + List bindParams = new ArrayList<>(); + + sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, " + + "is_for_sale, sale_price, reference, creation_group_id FROM Names"); + + if (after != null) { + sql.append(" WHERE registered_when > ? OR updated_when > ?"); + bindParams.add(after); + bindParams.add(after); + } + + sql.append(" ORDER BY name"); + + if (reverse != null && reverse) + sql.append(" DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List names = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return names; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBVotingRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBVotingRepository.java index 447fbe4c..cc33426b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBVotingRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBVotingRepository.java @@ -21,6 +21,55 @@ public class HSQLDBVotingRepository implements VotingRepository { // Polls + @Override + public List getAllPolls(Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(512); + + sql.append("SELECT poll_name, description, creator, owner, published_when FROM Polls ORDER BY poll_name"); + + if (reverse != null && reverse) + sql.append(" DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List polls = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + if (resultSet == null) + return polls; + + do { + String pollName = resultSet.getString(1); + String description = resultSet.getString(2); + byte[] creatorPublicKey = resultSet.getBytes(3); + String owner = resultSet.getString(4); + long published = resultSet.getLong(5); + + String optionsSql = "SELECT option_name FROM PollOptions WHERE poll_name = ? ORDER BY option_index ASC"; + try (ResultSet optionsResultSet = this.repository.checkedExecute(optionsSql, pollName)) { + if (optionsResultSet == null) + return null; + + List pollOptions = new ArrayList<>(); + + // NOTE: do-while because checkedExecute() above has already called rs.next() for us + do { + String optionName = optionsResultSet.getString(1); + + pollOptions.add(new PollOptionData(optionName)); + } while (optionsResultSet.next()); + + polls.add(new PollData(creatorPublicKey, owner, pollName, description, pollOptions, published)); + } + + } while (resultSet.next()); + + return polls; + } catch (SQLException e) { + throw new DataException("Unable to fetch polls from repository", e); + } + } + @Override public PollData fromPollName(String pollName) throws DataException { String sql = "SELECT description, creator, owner, published_when FROM Polls WHERE poll_name = ?"; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java index c7f4c958..57b75a29 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java @@ -31,7 +31,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos int version = resultSet.getInt(1); int nonce = resultSet.getInt(2); - Service service = Service.valueOf(resultSet.getInt(3)); + int serviceInt = resultSet.getInt(3); int size = resultSet.getInt(4); boolean isDataRaw = resultSet.getBoolean(5); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -44,7 +44,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.valueOf(resultSet.getInt(12)); List payments = this.getPaymentsFromSignature(baseTransactionData.getSignature()); - return new ArbitraryTransactionData(baseTransactionData, version, service, nonce, size, name, + return new ArbitraryTransactionData(baseTransactionData, version, serviceInt, nonce, size, name, identifier, method, secret, compression, data, dataType, metadataHash, payments); } catch (SQLException e) { throw new DataException("Unable to fetch arbitrary transaction from repository", e); @@ -66,7 +66,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos HSQLDBSaver saveHelper = new HSQLDBSaver("ArbitraryTransactions"); saveHelper.bind("signature", arbitraryTransactionData.getSignature()).bind("sender", arbitraryTransactionData.getSenderPublicKey()) - .bind("version", arbitraryTransactionData.getVersion()).bind("service", arbitraryTransactionData.getService().value) + .bind("version", arbitraryTransactionData.getVersion()).bind("service", arbitraryTransactionData.getServiceInt()) .bind("nonce", arbitraryTransactionData.getNonce()).bind("size", arbitraryTransactionData.getSize()) .bind("is_data_raw", arbitraryTransactionData.getDataType() == DataType.RAW_DATA).bind("data", arbitraryTransactionData.getData()) .bind("metadata_hash", arbitraryTransactionData.getMetadataHash()).bind("name", arbitraryTransactionData.getName()) diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java index 5f2ea35a..fc8e0bb3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java @@ -17,15 +17,16 @@ public class HSQLDBCancelSellNameTransactionRepository extends HSQLDBTransaction } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT name FROM CancelSellNameTransactions WHERE signature = ?"; + String sql = "SELECT name, sale_price FROM CancelSellNameTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) return null; String name = resultSet.getString(1); + Long salePrice = resultSet.getLong(2); - return new CancelSellNameTransactionData(baseTransactionData, name); + return new CancelSellNameTransactionData(baseTransactionData, name, salePrice); } catch (SQLException e) { throw new DataException("Unable to fetch cancel sell name transaction from repository", e); } @@ -38,7 +39,7 @@ public class HSQLDBCancelSellNameTransactionRepository extends HSQLDBTransaction HSQLDBSaver saveHelper = new HSQLDBSaver("CancelSellNameTransactions"); saveHelper.bind("signature", cancelSellNameTransactionData.getSignature()).bind("owner", cancelSellNameTransactionData.getOwnerPublicKey()).bind("name", - cancelSellNameTransactionData.getName()); + cancelSellNameTransactionData.getName()).bind("sale_price", cancelSellNameTransactionData.getSalePrice()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java index 449922f4..79e798a9 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java @@ -17,7 +17,7 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT sender, nonce, recipient, is_text, is_encrypted, data FROM ChatTransactions WHERE signature = ?"; + String sql = "SELECT sender, nonce, recipient, is_text, is_encrypted, data, chat_reference FROM ChatTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) @@ -29,8 +29,9 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository boolean isText = resultSet.getBoolean(4); boolean isEncrypted = resultSet.getBoolean(5); byte[] data = resultSet.getBytes(6); + byte[] chatReference = resultSet.getBytes(7); - return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, chatReference, data, isText, isEncrypted); } catch (SQLException e) { throw new DataException("Unable to fetch chat transaction from repository", e); } @@ -45,7 +46,7 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository saveHelper.bind("signature", chatTransactionData.getSignature()).bind("nonce", chatTransactionData.getNonce()) .bind("sender", chatTransactionData.getSender()).bind("recipient", chatTransactionData.getRecipient()) .bind("is_text", chatTransactionData.getIsText()).bind("is_encrypted", chatTransactionData.getIsEncrypted()) - .bind("data", chatTransactionData.getData()); + .bind("data", chatTransactionData.getData()).bind("chat_reference", chatTransactionData.getChatReference()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index e3ef13be..60b4e803 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -7,11 +7,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.EnumSet; -import java.util.List; -import java.util.Map; +import java.util.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -198,8 +194,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository { @Override public TransactionData fromHeightAndSequence(int height, int sequence) throws DataException { - String sql = "SELECT transaction_signature FROM BlockTransactions JOIN Blocks ON signature = block_signature " - + "WHERE height = ? AND sequence = ?"; + String sql = "SELECT signature FROM Transactions WHERE block_height = ? AND block_sequence = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, height, sequence)) { if (resultSet == null) @@ -661,8 +656,13 @@ public class HSQLDBTransactionRepository implements TransactionRepository { List bindParams) throws DataException { List signatures = new ArrayList<>(); + String txTypeClassName = ""; + if (txType != null) { + txTypeClassName = txType.className; + } + StringBuilder sql = new StringBuilder(1024); - sql.append(String.format("SELECT signature FROM %sTransactions", txType.className)); + sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName)); if (!whereClauses.isEmpty()) { sql.append(" WHERE "); @@ -694,6 +694,53 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + public List getSignaturesMatchingCustomCriteria(TransactionType txType, List whereClauses, + List bindParams, Integer limit) throws DataException { + List signatures = new ArrayList<>(); + + String txTypeClassName = ""; + if (txType != null) { + txTypeClassName = txType.className; + } + + StringBuilder sql = new StringBuilder(1024); + sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName)); + + if (!whereClauses.isEmpty()) { + sql.append(" WHERE "); + + final int whereClausesSize = whereClauses.size(); + for (int wci = 0; wci < whereClausesSize; ++wci) { + if (wci != 0) + sql.append(" AND "); + + sql.append(whereClauses.get(wci)); + } + } + + if (limit != null) { + sql.append(" LIMIT ?"); + bindParams.add(limit); + } + + LOGGER.trace(() -> String.format("Transaction search SQL: %s", sql)); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return signatures; + + do { + byte[] signature = resultSet.getBytes(1); + + signatures.add(signature); + } while (resultSet.next()); + + return signatures; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching transaction signatures from repository", e); + } + } + @Override public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException { StringBuilder sql = new StringBuilder(1024); @@ -969,6 +1016,33 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + public List getConfirmedRewardShareCreatorsExcludingSelfShares() throws DataException { + List rewardShareCreators = new ArrayList<>(); + + String sql = "SELECT account " + + "FROM RewardShareTransactions " + + "JOIN Accounts ON Accounts.public_key = RewardShareTransactions.minter_public_key " + + "JOIN Transactions ON Transactions.signature = RewardShareTransactions.signature " + + "WHERE block_height IS NOT NULL AND RewardShareTransactions.recipient != Accounts.account " + + "GROUP BY account " + + "ORDER BY account"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return rewardShareCreators; + + do { + String address = resultSet.getString(1); + + rewardShareCreators.add(address); + } while (resultSet.next()); + + return rewardShareCreators; + } catch (SQLException e) { + throw new DataException("Unable to fetch reward share creators from repository", e); + } + } + @Override public List getApprovalPendingTransactions(Integer txGroupId, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); @@ -1355,8 +1429,10 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } @Override - public List getUnconfirmedTransactions(EnumSet excludedTxTypes) throws DataException { + public List getUnconfirmedTransactions(EnumSet excludedTxTypes, Integer limit) throws DataException { StringBuilder sql = new StringBuilder(1024); + List bindParams = new ArrayList<>(); + sql.append("SELECT signature FROM UnconfirmedTransactions "); sql.append("JOIN Transactions USING (signature) "); sql.append("WHERE type NOT IN ("); @@ -1372,12 +1448,17 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } sql.append(")"); - sql.append("ORDER BY created_when, signature"); + sql.append("ORDER BY created_when, signature "); + + if (limit != null) { + sql.append("LIMIT ?"); + bindParams.add(limit); + } List transactions = new ArrayList<>(); // Find transactions with no corresponding row in BlockTransactions - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return transactions; @@ -1421,6 +1502,19 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + @Override + public void updateBlockSequence(byte[] signature, Integer blockSequence) throws DataException { + HSQLDBSaver saver = new HSQLDBSaver("Transactions"); + + saver.bind("signature", signature).bind("block_sequence", blockSequence); + + try { + saver.execute(repository); + } catch (SQLException e) { + throw new DataException("Unable to update transaction's block sequence in repository", e); + } + } + @Override public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException { HSQLDBSaver saver = new HSQLDBSaver("Transactions"); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 5b8d609e..bdff9506 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -47,6 +47,9 @@ public class Settings { private static final int MAINNET_GATEWAY_PORT = 80; private static final int TESTNET_GATEWAY_PORT = 8080; + private static final int MAINNET_DEV_PROXY_PORT = 12393; + private static final int TESTNET_DEV_PROXY_PORT = 62393; + private static final Logger LOGGER = LogManager.getLogger(Settings.class); private static final String SETTINGS_FILENAME = "settings.json"; @@ -61,6 +64,7 @@ public class Settings { // Common to all networking (API/P2P) private String bindAddress = "::"; // Use IPv6 wildcard to listen on all local addresses + private String bindAddressFallback = "0.0.0.0"; // Some systems are unable to bind using IPv6 // UI servers private int uiPort = 12388; @@ -104,13 +108,25 @@ public class Settings { private Integer gatewayPort; private boolean gatewayEnabled = false; private boolean gatewayLoggingEnabled = false; + private boolean gatewayLoopbackEnabled = false; + + // Developer Proxy + private Integer devProxyPort; + private boolean devProxyLoggingEnabled = false; + // Specific to this node private boolean wipeUnconfirmedOnStart = false; /** Maximum number of unconfirmed transactions allowed per account */ private int maxUnconfirmedPerAccount = 25; /** Max milliseconds into future for accepting new, unconfirmed transactions */ - private int maxTransactionTimestampFuture = 24 * 60 * 60 * 1000; // milliseconds + private int maxTransactionTimestampFuture = 30 * 60 * 1000; // milliseconds + + /** Maximum number of CHAT transactions allowed per account in recent timeframe */ + private int maxRecentChatMessagesPerAccount = 250; + /** Maximum age of a CHAT transaction to be considered 'recent' */ + private long recentChatMessagesMaxAge = 60 * 60 * 1000L; // milliseconds + /** Whether we check, fetch and install auto-updates */ private boolean autoUpdateEnabled = true; /** How long between repository backups (ms), or 0 if disabled. */ @@ -130,6 +146,9 @@ public class Settings { /* How many blocks to cache locally. Defaulted to 10, which covers a typical Synchronizer request + a few spare */ private int blockCacheSize = 10; + /** Maximum number of transactions for the block minter to include in a block */ + private int maxTransactionsPerBlock = 50; + /** How long to keep old, full, AT state data (ms). */ private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds /** How often to attempt AT state trimming (ms). */ @@ -153,7 +172,7 @@ public class Settings { * This prevents the node from being able to serve older blocks */ private boolean topOnly = false; /** The amount of recent blocks we should keep when pruning */ - private int pruneBlockLimit = 1450; + private int pruneBlockLimit = 6000; /** How often to attempt AT state pruning (ms). */ private long atStatesPruneInterval = 3219L; // milliseconds @@ -172,6 +191,8 @@ public class Settings { private boolean archiveEnabled = true; /** How often to attempt archiving (ms). */ private long archiveInterval = 7171L; // milliseconds + /** Serialization version to use when building an archive */ + private int defaultArchiveVersion = 2; /** Whether to automatically bootstrap instead of syncing from genesis */ @@ -184,27 +205,32 @@ public class Settings { // Peer-to-peer related private boolean isTestNet = false; + /** Single node testnet mode */ + private boolean singleNodeTestnet = false; /** Port number for inbound peer-to-peer connections. */ private Integer listenPort; /** Whether to attempt to open the listen port via UPnP */ private boolean uPnPEnabled = true; /** Minimum number of peers to allow block minting / synchronization. */ - private int minBlockchainPeers = 5; + private int minBlockchainPeers = 3; /** Target number of outbound connections to peers we should make. */ private int minOutboundPeers = 16; /** Maximum number of peer connections we allow. */ - private int maxPeers = 36; + private int maxPeers = 40; /** Number of slots to reserve for short-lived QDN data transfers */ private int maxDataPeers = 4; /** Maximum number of threads for network engine. */ - private int maxNetworkThreadPoolSize = 32; + private int maxNetworkThreadPoolSize = 120; /** Maximum number of threads for network proof-of-work compute, used during handshaking. */ private int networkPoWComputePoolSize = 2; /** Maximum number of retry attempts if a peer fails to respond with the requested data */ private int maxRetries = 2; + /** The number of seconds of no activity before recovery mode begins */ + public long recoveryModeTimeout = 9999999999999L; + /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.3.7"; + private String minPeerVersion = "4.3.0"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ @@ -238,6 +264,9 @@ public class Settings { /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; + /** Maximum buy attempts for each trade offer before it is considered failed, and hidden from the list */ + private int maxTradeOfferAttempts = 3; + /** Wallets path - used for storing encrypted wallet caches for coins that require them */ private String walletsPath = "wallets"; @@ -249,7 +278,7 @@ public class Settings { /** Repository storage path. */ private String repositoryPath = "db"; /** Repository connection pool size. Needs to be a bit bigger than maxNetworkThreadPoolSize */ - private int repositoryConnectionPoolSize = 100; + private int repositoryConnectionPoolSize = 240; private List fixedNetwork; // Export/import @@ -262,7 +291,8 @@ public class Settings { private String[] bootstrapHosts = new String[] { "http://bootstrap.qortal.org", "http://bootstrap2.qortal.org", - "http://62.171.190.193" + "http://bootstrap3.qortal.org", + "http://bootstrap.qortal.online" }; // Auto-update sources @@ -290,10 +320,6 @@ public class Settings { /** Additional offset added to values returned by NTP.getTime() */ private Long testNtpOffset = null; - // Online accounts - - /** Whether to opt-in to mempow computations for online accounts, ahead of general release */ - private boolean onlineAccountsMemPoWEnabled = false; /* Foreign chains */ @@ -343,7 +369,7 @@ public class Settings { private Long maxStorageCapacity = null; /** Whether to serve QDN data without authentication */ - private boolean qdnAuthBypassEnabled = false; + private boolean qdnAuthBypassEnabled = true; // Domain mapping public static class DomainMap { @@ -490,9 +516,12 @@ public class Settings { private void validate() { // Validation goes here - if (this.minBlockchainPeers < 1) + if (this.minBlockchainPeers < 1 && !singleNodeTestnet) throwValidationError("minBlockchainPeers must be at least 1"); + if (this.topOnly) + throwValidationError("topOnly mode is no longer supported"); + if (this.apiKey != null && this.apiKey.trim().length() < 8) throwValidationError("apiKey must be at least 8 characters"); @@ -626,6 +655,22 @@ public class Settings { return this.gatewayLoggingEnabled; } + public boolean isGatewayLoopbackEnabled() { + return this.gatewayLoopbackEnabled; + } + + + public int getDevProxyPort() { + if (this.devProxyPort != null) + return this.devProxyPort; + + return this.isTestNet ? TESTNET_DEV_PROXY_PORT : MAINNET_DEV_PROXY_PORT; + } + + public boolean isDevProxyLoggingEnabled() { + return this.devProxyLoggingEnabled; + } + public boolean getWipeUnconfirmedOnStart() { return this.wipeUnconfirmedOnStart; @@ -639,14 +684,30 @@ public class Settings { return this.maxTransactionTimestampFuture; } + public int getMaxRecentChatMessagesPerAccount() { + return this.maxRecentChatMessagesPerAccount; + } + + public long getRecentChatMessagesMaxAge() { + return recentChatMessagesMaxAge; + } + public int getBlockCacheSize() { return this.blockCacheSize; } + public int getMaxTransactionsPerBlock() { + return this.maxTransactionsPerBlock; + } + public boolean isTestNet() { return this.isTestNet; } + public boolean isSingleNodeTestnet() { + return this.singleNodeTestnet; + } + public int getListenPort() { if (this.listenPort != null) return this.listenPort; @@ -662,11 +723,18 @@ public class Settings { return this.bindAddress; } + public String getBindAddressFallback() { + return this.bindAddressFallback; + } + public boolean isUPnPEnabled() { return this.uPnPEnabled; } public int getMinBlockchainPeers() { + if (singleNodeTestnet) + return 0; + return this.minBlockchainPeers; } @@ -692,6 +760,10 @@ public class Settings { public int getMaxRetries() { return this.maxRetries; } + public long getRecoveryModeTimeout() { + return recoveryModeTimeout; + } + public String getMinPeerVersion() { return this.minPeerVersion; } public boolean getAllowConnectionsWithOlderPeerVersions() { return this.allowConnectionsWithOlderPeerVersions; } @@ -732,6 +804,10 @@ public class Settings { return this.pirateChainNet; } + public int getMaxTradeOfferAttempts() { + return this.maxTradeOfferAttempts; + } + public String getWalletsPath() { return this.walletsPath; } @@ -800,10 +876,6 @@ public class Settings { return this.testNtpOffset; } - public boolean isOnlineAccountsMemPoWEnabled() { - return this.onlineAccountsMemPoWEnabled; - } - public long getRepositoryBackupInterval() { return this.repositoryBackupInterval; } @@ -904,6 +976,10 @@ public class Settings { return this.archiveInterval; } + public int getDefaultArchiveVersion() { + return this.defaultArchiveVersion; + } + public boolean getBootstrap() { return this.bootstrap; @@ -972,6 +1048,10 @@ public class Settings { } public boolean isQDNAuthBypassEnabled() { + if (this.gatewayEnabled) { + // We must always bypass QDN authentication in gateway mode, in order for it to function properly + return true; + } return this.qdnAuthBypassEnabled; } } diff --git a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java index 15dc51bf..f38638c5 100644 --- a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; @@ -64,15 +65,24 @@ public class AddGroupAdminTransaction extends Transaction { Account owner = getOwner(); String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); - // Check transaction's public key matches group's current owner - if (!owner.getAddress().equals(groupOwner)) + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + + // Check transaction's public key matches group's current owner (except for groups owned by the null account) + if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; // Check address is a group member if (!this.repository.getGroupRepository().memberExists(groupId, memberAddress)) return ValidationResult.NOT_GROUP_MEMBER; + // Check transaction creator is a group member + if (!this.repository.getGroupRepository().memberExists(groupId, this.getCreator().getAddress())) + return ValidationResult.NOT_GROUP_MEMBER; + // Check group member is not already an admin if (this.repository.getGroupRepository().adminExists(groupId, memberAddress)) return ValidationResult.ALREADY_GROUP_ADMIN; diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index ca5ce517..a3f4827b 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -1,6 +1,5 @@ package org.qortal.transaction; -import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -8,7 +7,7 @@ import java.util.stream.Collectors; import org.qortal.account.Account; import org.qortal.block.BlockChain; import org.qortal.controller.arbitrary.ArbitraryDataManager; -import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.crypto.Crypto; import org.qortal.crypto.MemoryPoW; import org.qortal.data.PaymentData; @@ -24,6 +23,7 @@ import org.qortal.transform.Transformer; import org.qortal.transform.transaction.ArbitraryTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.ArbitraryTransactionUtils; +import org.qortal.utils.NTP; public class ArbitraryTransaction extends Transaction { @@ -31,12 +31,16 @@ public class ArbitraryTransaction extends Transaction { private ArbitraryTransactionData arbitraryTransactionData; // Other useful constants - public static final int MAX_DATA_SIZE = 4000; + public static final int MAX_DATA_SIZE = 256; public static final int MAX_METADATA_LENGTH = 32; public static final int HASH_LENGTH = TransactionTransformer.SHA256_LENGTH; - public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes public static final int MAX_IDENTIFIER_LENGTH = 64; + /** If time difference between transaction and now is greater than this then we don't verify proof-of-work. */ + public static final long HISTORIC_THRESHOLD = 2 * 7 * 24 * 60 * 60 * 1000L; + public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes + + // Constructors public ArbitraryTransaction(Repository repository, TransactionData transactionData) { @@ -82,6 +86,18 @@ public class ArbitraryTransaction extends Transaction { if (this.transactionData.getFee() < 0) return ValidationResult.NEGATIVE_FEE; + // As of the mempow transaction updates timestamp, a nonce is no longer supported, so a valid fee must be included + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + // Validate the fee + return super.isFeeValid(); + } + + // After the earlier "optional fee" feature trigger, we required the fee to be sufficient if it wasn't 0. + // If the fee was zero, then the nonce was validated in isSignatureValid() as an alternative to a fee + if (this.arbitraryTransactionData.getTimestamp() >= BlockChain.getInstance().getArbitraryOptionalFeeTimestamp() && this.arbitraryTransactionData.getFee() != 0L) { + return super.isFeeValid(); + } + return ValidationResult.OK; } @@ -202,9 +218,21 @@ public class ArbitraryTransaction extends Transaction { // Clear nonce from transactionBytes ArbitraryTransactionTransformer.clearNonce(transactionBytes); - // Check nonce - int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); - return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + // As of the mempow transaction updates timestamp, a nonce is no longer supported, so a fee must be included + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + // Require that the fee is a positive number. Fee checking itself is performed in isFeeValid() + return (this.arbitraryTransactionData.getFee() > 0L); + } + + // As of the earlier "optional fee" feature-trigger timestamp, we only required a nonce when the fee was zero + boolean beforeFeatureTrigger = this.arbitraryTransactionData.getTimestamp() < BlockChain.getInstance().getArbitraryOptionalFeeTimestamp(); + if (beforeFeatureTrigger || this.arbitraryTransactionData.getFee() == 0L) { + // We only need to check nonce for recent transactions due to PoW verification overhead + if (NTP.getTime() - this.arbitraryTransactionData.getTimestamp() < HISTORIC_THRESHOLD) { + int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + } + } } return true; @@ -234,7 +262,15 @@ public class ArbitraryTransaction extends Transaction { @Override public void preProcess() throws DataException { - // Nothing to do + ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) transactionData; + if (arbitraryTransactionData.getName() == null) + return; + + // Rebuild this name in the Names table from the transaction history + // This is necessary because in some rare cases names can be missing from the Names table after registration + // but we have been unable to reproduce the issue and track down the root cause + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildName(arbitraryTransactionData.getName(), this.repository); } @Override diff --git a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java index 483dfc6f..08d9cb3e 100644 --- a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java @@ -73,7 +73,7 @@ public class CancelGroupBanTransaction extends Transaction { Account member = getMember(); // Check ban actually exists - if (!this.repository.getGroupRepository().banExists(groupId, member.getAddress())) + if (!this.repository.getGroupRepository().banExists(groupId, member.getAddress(), this.groupUnbanTransactionData.getTimestamp())) return ValidationResult.BAN_UNKNOWN; // Check admin has enough funds diff --git a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java index 788492a9..876f0aed 100644 --- a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java @@ -5,6 +5,7 @@ import java.util.List; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.data.naming.NameData; import org.qortal.data.transaction.CancelSellNameTransactionData; import org.qortal.data.transaction.TransactionData; @@ -81,7 +82,13 @@ public class CancelSellNameTransaction extends Transaction { @Override public void preProcess() throws DataException { - // Nothing to do + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) transactionData; + + // Rebuild this name in the Names table from the transaction history + // This is necessary because in some rare cases names can be missing from the Names table after registration + // but we have been unable to reproduce the issue and track down the root cause + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildName(cancelSellNameTransactionData.getName(), this.repository); } @Override diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index 9cccd42a..3d968461 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -1,11 +1,14 @@ package org.qortal.transaction; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.Predicate; import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; +import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; import org.qortal.crypto.Crypto; import org.qortal.crypto.MemoryPoW; import org.qortal.data.naming.NameData; @@ -16,9 +19,12 @@ import org.qortal.list.ResourceListManager; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; import org.qortal.repository.Repository; +import org.qortal.settings.Settings; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.ChatTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.ListUtils; +import org.qortal.utils.NTP; public class ChatTransaction extends Transaction { @@ -26,10 +32,11 @@ public class ChatTransaction extends Transaction { private ChatTransactionData chatTransactionData; // Other useful constants - public static final int MAX_DATA_SIZE = 1024; + public static final int MAX_DATA_SIZE = 4000; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY_WITH_QORT = 8; // leading zero bits - public static final int POW_DIFFICULTY_NO_QORT = 12; // leading zero bits + public static final int POW_DIFFICULTY_ABOVE_QORT_THRESHOLD = 8; // leading zero bits + public static final int POW_DIFFICULTY_BELOW_QORT_THRESHOLD = 18; // leading zero bits + public static final long POW_QORT_THRESHOLD = 400000000L; // Constructors @@ -78,7 +85,7 @@ public class ChatTransaction extends Transaction { // Clear nonce from transactionBytes ChatTransactionTransformer.clearNonce(transactionBytes); - int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) >= POW_QORT_THRESHOLD ? POW_DIFFICULTY_ABOVE_QORT_THRESHOLD : POW_DIFFICULTY_BELOW_QORT_THRESHOLD; // Calculate nonce this.chatTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, difficulty)); @@ -141,13 +148,23 @@ public class ChatTransaction extends Transaction { // Nothing to do } + @Override + public boolean isConfirmable() { + // CHAT transactions can't go into blocks + return false; + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import + // Disregard messages with timestamp too far in the future (we have stricter limits for CHAT transactions) + if (this.chatTransactionData.getTimestamp() > NTP.getTime() + (5 * 60 * 1000L)) { + return ValidationResult.TIMESTAMP_TOO_NEW; + } + // Check for blocked author by address - ResourceListManager listManager = ResourceListManager.getInstance(); - if (listManager.listContains("blockedAddresses", this.chatTransactionData.getSender(), true)) { + if (ListUtils.isAddressBlocked(this.chatTransactionData.getSender())) { return ValidationResult.ADDRESS_BLOCKED; } @@ -156,13 +173,21 @@ public class ChatTransaction extends Transaction { if (names != null && names.size() > 0) { for (NameData nameData : names) { if (nameData != null && nameData.getName() != null) { - if (listManager.listContains("blockedNames", nameData.getName(), false)) { + if (ListUtils.isNameBlocked(nameData.getName())) { return ValidationResult.NAME_BLOCKED; } } } } + PublicKeyAccount creator = this.getCreator(); + if (creator == null) + return ValidationResult.MISSING_CREATOR; + + // Reject if unconfirmed pile already has X recent CHAT transactions from same creator + if (countRecentChatTransactionsByCreator(creator) >= Settings.getInstance().getMaxRecentChatMessagesPerAccount()) + return ValidationResult.TOO_MANY_UNCONFIRMED; + // If we exist in the repository then we've been imported as unconfirmed, // but we don't want to make it into a block, so return fake non-OK result. if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature())) @@ -204,7 +229,7 @@ public class ChatTransaction extends Transaction { int difficulty; try { - difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + difficulty = this.getSender().getConfirmedBalance(Asset.QORT) >= POW_QORT_THRESHOLD ? POW_DIFFICULTY_ABOVE_QORT_THRESHOLD : POW_DIFFICULTY_BELOW_QORT_THRESHOLD; } catch (DataException e) { return false; } @@ -213,6 +238,26 @@ public class ChatTransaction extends Transaction { return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); } + private int countRecentChatTransactionsByCreator(PublicKeyAccount creator) throws DataException { + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + final Long now = NTP.getTime(); + long recentThreshold = Settings.getInstance().getRecentChatMessagesMaxAge(); + + // We only care about chat transactions, and only those that are considered 'recent' + Predicate hasSameCreatorAndIsRecentChat = transactionData -> { + if (transactionData.getType() != TransactionType.CHAT) + return false; + + if (transactionData.getTimestamp() < now - recentThreshold) + return false; + + return Arrays.equals(creator.getPublicKey(), transactionData.getCreatorPublicKey()); + }; + + return (int) unconfirmedTransactions.stream().filter(hasSameCreatorAndIsRecentChat).count(); + } + + /** * Ensure there's at least a skeleton account so people * can retrieve sender's public key using address, even if all their messages diff --git a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java index f3b08f59..fa5e7b85 100644 --- a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java @@ -78,7 +78,7 @@ public class GroupInviteTransaction extends Transaction { return ValidationResult.ALREADY_GROUP_MEMBER; // Check invitee is not banned - if (this.repository.getGroupRepository().banExists(groupId, invitee.getAddress())) + if (this.repository.getGroupRepository().banExists(groupId, invitee.getAddress(), this.groupInviteTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; // Check creator has enough funds diff --git a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java index bc62c629..3061a3fb 100644 --- a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java @@ -53,7 +53,7 @@ public class JoinGroupTransaction extends Transaction { return ValidationResult.ALREADY_GROUP_MEMBER; // Check member is not banned - if (this.repository.getGroupRepository().banExists(groupId, joiner.getAddress())) + if (this.repository.getGroupRepository().banExists(groupId, joiner.getAddress(), this.joinGroupTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; // Check join request doesn't already exist diff --git a/src/main/java/org/qortal/transaction/MessageTransaction.java b/src/main/java/org/qortal/transaction/MessageTransaction.java index a9d3a01c..b61c3d11 100644 --- a/src/main/java/org/qortal/transaction/MessageTransaction.java +++ b/src/main/java/org/qortal/transaction/MessageTransaction.java @@ -33,7 +33,9 @@ public class MessageTransaction extends Transaction { public static final int MAX_DATA_SIZE = 4000; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY = 14; // leading zero bits + public static final int POW_DIFFICULTY_V1 = 14; // leading zero bits + public static final int POW_DIFFICULTY_V2_CONFIRMABLE = 16; // leading zero bits + public static final int POW_DIFFICULTY_V2_UNCONFIRMABLE = 12; // leading zero bits // Properties @@ -109,7 +111,17 @@ public class MessageTransaction extends Transaction { MessageTransactionTransformer.clearNonce(transactionBytes); // Calculate nonce - this.messageTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY)); + this.messageTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, getPoWDifficulty())); + } + + public int getPoWDifficulty() { + // The difficulty changes at the "mempow transactions updates" timestamp + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + // If this message is confirmable then require a higher difficulty + return this.isConfirmable() ? POW_DIFFICULTY_V2_CONFIRMABLE : POW_DIFFICULTY_V2_UNCONFIRMABLE; + } + // Before feature trigger timestamp, so use existing difficulty value + return POW_DIFFICULTY_V1; } /** @@ -183,6 +195,18 @@ public class MessageTransaction extends Transaction { return super.hasValidReference(); } + @Override + public boolean isConfirmable() { + // After feature trigger timestamp, only messages to an AT address can confirm + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + if (this.messageTransactionData.getRecipient() == null || !this.messageTransactionData.getRecipient().toUpperCase().startsWith("A")) { + // Message isn't to an AT address, so this transaction is unconfirmable + return false; + } + } + return true; + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import @@ -235,7 +259,7 @@ public class MessageTransaction extends Transaction { MessageTransactionTransformer.clearNonce(transactionBytes); // Check nonce - return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, getPoWDifficulty(), nonce); } @Override @@ -256,6 +280,11 @@ public class MessageTransaction extends Transaction { @Override public void process() throws DataException { + // Only certain MESSAGE transactions are able to confirm + if (!this.isConfirmable()) { + throw new DataException("Unconfirmable MESSAGE transactions should never be processed"); + } + // If we have no amount then there's nothing to do if (this.messageTransactionData.getAmount() == 0L) return; @@ -280,6 +309,11 @@ public class MessageTransaction extends Transaction { @Override public void orphan() throws DataException { + // Only certain MESSAGE transactions are able to confirm + if (!this.isConfirmable()) { + throw new DataException("Unconfirmable MESSAGE transactions should never be orphaned"); + } + // If we have no amount then there's nothing to do if (this.messageTransactionData.getAmount() == 0L) return; diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java index 8076997c..56a9f633 100644 --- a/src/main/java/org/qortal/transaction/PresenceTransaction.java +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -155,6 +155,12 @@ public class PresenceTransaction extends Transaction { // Nothing to do } + @Override + public boolean isConfirmable() { + // PRESENCE transactions can't go into blocks + return false; + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import diff --git a/src/main/java/org/qortal/transaction/PublicizeTransaction.java b/src/main/java/org/qortal/transaction/PublicizeTransaction.java index c03c8283..44f93e6e 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -4,7 +4,10 @@ import java.util.Collections; import java.util.List; import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; +import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.crypto.MemoryPoW; import org.qortal.data.transaction.PublicizeTransactionData; import org.qortal.data.transaction.TransactionData; @@ -26,7 +29,7 @@ public class PublicizeTransaction extends Transaction { /** If time difference between transaction and now is greater than this then we don't verify proof-of-work. */ public static final long HISTORIC_THRESHOLD = 2 * 7 * 24 * 60 * 60 * 1000L; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY = 15; // leading zero bits + public static final int POW_DIFFICULTY = 14; // leading zero bits // Constructors @@ -87,6 +90,12 @@ public class PublicizeTransaction extends Transaction { @Override public ValidationResult isValid() throws DataException { + // Disable completely after feature-trigger timestamp, at the same time that mempow difficulties are being increased. + // It could be enabled again in the future, but preferably with an enforced minimum fee instead of allowing a mempow nonce. + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + return ValidationResult.NOT_SUPPORTED; + } + // There can be only one List signatures = this.repository.getTransactionRepository().getSignaturesMatchingCriteria( TransactionType.PUBLICIZE, @@ -102,6 +111,12 @@ public class PublicizeTransaction extends Transaction { if (!verifyNonce()) return ValidationResult.INCORRECT_NONCE; + // Validate fee if one has been included + PublicKeyAccount creator = this.getCreator(); + if (this.transactionData.getFee() > 0) + if (creator.getConfirmedBalance(Asset.QORT) < this.transactionData.getFee()) + return ValidationResult.NO_BALANCE; + return ValidationResult.OK; } diff --git a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java index 3e5f1e6d..043b5423 100644 --- a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; @@ -65,11 +66,21 @@ public class RemoveGroupAdminTransaction extends Transaction { return ValidationResult.GROUP_DOES_NOT_EXIST; Account owner = getOwner(); + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); - // Check transaction's public key matches group's current owner - if (!owner.getAddress().equals(groupData.getOwner())) + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + + // Check transaction's public key matches group's current owner (except for groups owned by the null account) + if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; + // Check transaction creator is a group member + if (!this.repository.getGroupRepository().memberExists(groupId, this.getCreator().getAddress())) + return ValidationResult.NOT_GROUP_MEMBER; + Account admin = getAdmin(); // Check member is an admin diff --git a/src/main/java/org/qortal/transaction/RewardShareTransaction.java b/src/main/java/org/qortal/transaction/RewardShareTransaction.java index ed5029b2..d4d2434c 100644 --- a/src/main/java/org/qortal/transaction/RewardShareTransaction.java +++ b/src/main/java/org/qortal/transaction/RewardShareTransaction.java @@ -163,9 +163,12 @@ public class RewardShareTransaction extends Transaction { return ValidationResult.SELF_SHARE_EXISTS; } - // Fee checking needed if not setting up new self-share - if (!(isRecipientAlsoMinter && existingRewardShareData == null)) - // Check creator has enough funds + // Check creator has enough funds + if (this.rewardShareTransactionData.getTimestamp() >= BlockChain.getInstance().getFeeValidationFixTimestamp()) + if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) + return ValidationResult.NO_BALANCE; + + else if (!(isRecipientAlsoMinter && existingRewardShareData == null)) if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) return ValidationResult.NO_BALANCE; diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index b56d48cf..e0ed1f82 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -1,13 +1,7 @@ package org.qortal.transaction; import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; @@ -19,6 +13,7 @@ import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; import org.qortal.block.BlockChain; import org.qortal.controller.Controller; +import org.qortal.controller.TransactionImporter; import org.qortal.crypto.Crypto; import org.qortal.data.block.BlockData; import org.qortal.data.group.GroupApprovalData; @@ -69,8 +64,8 @@ public abstract class Transaction { AT(21, false), CREATE_GROUP(22, true), UPDATE_GROUP(23, true), - ADD_GROUP_ADMIN(24, false), - REMOVE_GROUP_ADMIN(25, false), + ADD_GROUP_ADMIN(24, true), + REMOVE_GROUP_ADMIN(25, true), GROUP_BAN(26, false), CANCEL_GROUP_BAN(27, false), GROUP_KICK(28, false), @@ -250,8 +245,11 @@ public abstract class Transaction { INVALID_TIMESTAMP_SIGNATURE(95), ADDRESS_BLOCKED(96), NAME_BLOCKED(97), + GROUP_APPROVAL_REQUIRED(98), + ACCOUNT_NOT_TRANSFERABLE(99), INVALID_BUT_OK(999), - NOT_YET_RELEASED(1000); + NOT_YET_RELEASED(1000), + NOT_SUPPORTED(1001); public final int value; @@ -381,7 +379,7 @@ public abstract class Transaction { * @return */ public long getUnitFee(Long timestamp) { - return BlockChain.getInstance().getUnitFee(); + return BlockChain.getInstance().getUnitFeeAtTimestamp(timestamp); } /** @@ -621,7 +619,10 @@ public abstract class Transaction { } private int countUnconfirmedByCreator(PublicKeyAccount creator) throws DataException { - List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + List unconfirmedTransactions = TransactionImporter.getInstance().unconfirmedTransactionsCache; + if (unconfirmedTransactions == null) { + unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + } // We exclude CHAT transactions as they never get included into blocks and // have spam/DoS prevention by requiring proof of work @@ -636,7 +637,7 @@ public abstract class Transaction { } /** - * Returns sorted, unconfirmed transactions, excluding invalid. + * Returns sorted, unconfirmed transactions, excluding invalid and unconfirmable. * * @return sorted, unconfirmed transactions * @throws DataException @@ -645,7 +646,7 @@ public abstract class Transaction { BlockData latestBlockData = repository.getBlockRepository().getLastBlock(); EnumSet excludedTxTypes = EnumSet.of(TransactionType.CHAT, TransactionType.PRESENCE); - List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes); + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes, null); unconfirmedTransactions.sort(getDataComparator()); @@ -654,7 +655,8 @@ public abstract class Transaction { TransactionData transactionData = unconfirmedTransactionsIterator.next(); Transaction transaction = Transaction.fromData(repository, transactionData); - if (transaction.isStillValidUnconfirmed(latestBlockData.getTimestamp()) != ValidationResult.OK) + // Must be confirmable and valid + if (!transaction.isConfirmable() || transaction.isStillValidUnconfirmed(latestBlockData.getTimestamp()) != ValidationResult.OK) unconfirmedTransactionsIterator.remove(); } @@ -760,9 +762,13 @@ public abstract class Transaction { // Group no longer exists? Possibly due to blockchain orphaning undoing group creation? return true; // stops tx being included in block but it will eventually expire + String groupOwner = this.repository.getGroupRepository().getOwner(txGroupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + // If transaction's creator is group admin (of group with ID txGroupId) then auto-approve + // This is disabled for null-owned groups, since these require approval from other admins PublicKeyAccount creator = this.getCreator(); - if (groupRepository.adminExists(txGroupId, creator.getAddress())) + if (!groupOwnedByNullAccount && groupRepository.adminExists(txGroupId, creator.getAddress())) return false; return true; @@ -888,6 +894,17 @@ public abstract class Transaction { /* To be optionally overridden */ } + /** + * Returns whether transaction is 'confirmable' - i.e. is of a type that + * can be included in a block. Some transactions are 'unconfirmable' + * and therefore must remain in the mempool until they expire. + * @return + */ + public boolean isConfirmable() { + /* To be optionally overridden */ + return true; + } + /** * Returns whether transaction can be added to the blockchain. *

diff --git a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java index f6a9de68..97e67160 100644 --- a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java +++ b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java @@ -67,6 +67,11 @@ public class TransferPrivsTransaction extends Transaction { if (getSender().getConfirmedBalance(Asset.QORT) < this.transferPrivsTransactionData.getFee()) return ValidationResult.NO_BALANCE; + // Check sender doesn't have a blocksMintedPenalty, as these accounts cannot be transferred + AccountData senderAccountData = this.repository.getAccountRepository().getAccount(getSender().getAddress()); + if (senderAccountData == null || senderAccountData.getBlocksMintedPenalty() != 0) + return ValidationResult.ACCOUNT_NOT_TRANSFERABLE; + return ValidationResult.OK; } diff --git a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java index 9664ccbf..27580430 100644 --- a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java @@ -103,7 +103,7 @@ public class UpdateGroupTransaction extends Transaction { Account newOwner = getNewOwner(); // Check new owner is not banned - if (this.repository.getGroupRepository().banExists(this.updateGroupTransactionData.getGroupId(), newOwner.getAddress())) + if (this.repository.getGroupRepository().banExists(this.updateGroupTransactionData.getGroupId(), newOwner.getAddress(), this.updateGroupTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; return ValidationResult.OK; diff --git a/src/main/java/org/qortal/transform/block/BlockTransformer.java b/src/main/java/org/qortal/transform/block/BlockTransformer.java index 9e02a6f5..15445327 100644 --- a/src/main/java/org/qortal/transform/block/BlockTransformer.java +++ b/src/main/java/org/qortal/transform/block/BlockTransformer.java @@ -235,7 +235,7 @@ public class BlockTransformer extends Transformer { // Online accounts timestamp is only present if there are also signatures onlineAccountsTimestamp = byteBuffer.getLong(); - final int signaturesByteLength = getOnlineAccountSignaturesLength(onlineAccountsSignaturesCount, onlineAccountsCount, timestamp); + final int signaturesByteLength = (onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH) + (onlineAccountsCount * INT_LENGTH); if (signaturesByteLength > BlockChain.getInstance().getMaxBlockSize()) throw new TransformationException("Byte data too long for online accounts signatures"); @@ -312,16 +312,24 @@ public class BlockTransformer extends Transformer { ByteArrayOutputStream atHashBytes = new ByteArrayOutputStream(atBytesLength); long atFees = 0; - for (ATStateData atStateData : block.getATStates()) { - // Skip initial states generated by DEPLOY_AT transactions in the same block - if (atStateData.isInitial()) - continue; + if (block.getAtStatesHash() != null) { + // We already have the AT states hash + atFees = blockData.getATFees(); + atHashBytes.write(block.getAtStatesHash()); + } + else { + // We need to build the AT states hash + for (ATStateData atStateData : block.getATStates()) { + // Skip initial states generated by DEPLOY_AT transactions in the same block + if (atStateData.isInitial()) + continue; - atHashBytes.write(atStateData.getATAddress().getBytes(StandardCharsets.UTF_8)); - atHashBytes.write(atStateData.getStateHash()); - atHashBytes.write(Longs.toByteArray(atStateData.getFees())); + atHashBytes.write(atStateData.getATAddress().getBytes(StandardCharsets.UTF_8)); + atHashBytes.write(atStateData.getStateHash()); + atHashBytes.write(Longs.toByteArray(atStateData.getFees())); - atFees += atStateData.getFees(); + atFees += atStateData.getFees(); + } } bytes.write(Ints.toByteArray(blockData.getATCount())); @@ -511,16 +519,6 @@ public class BlockTransformer extends Transformer { return nonces; } - public static int getOnlineAccountSignaturesLength(int onlineAccountsSignaturesCount, int onlineAccountCount, long blockTimestamp) { - if (blockTimestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - // Once mempow is active, we expect the online account signatures to be appended with the nonce values - return (onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH) + (onlineAccountCount * INT_LENGTH); - } - else { - // Before mempow, only the online account signatures were included (which will likely be a single signature) - return onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH; - } - } public static byte[] extract(byte[] input, int pos, int length) { byte[] output = new byte[length]; diff --git a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java index b1554e8d..1ae80e1f 100644 --- a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.List; import com.google.common.base.Utf8; -import org.qortal.arbitrary.misc.Service; import org.qortal.crypto.Crypto; import org.qortal.data.PaymentData; import org.qortal.data.transaction.ArbitraryTransactionData; @@ -131,7 +130,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { payments.add(PaymentTransformer.fromByteBuffer(byteBuffer)); } - Service service = Service.valueOf(byteBuffer.getInt()); + int service = byteBuffer.getInt(); // We might be receiving hash of data instead of actual raw data boolean isRaw = byteBuffer.get() != 0; @@ -226,7 +225,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { for (PaymentData paymentData : payments) bytes.write(PaymentTransformer.toBytes(paymentData)); - bytes.write(Ints.toByteArray(arbitraryTransactionData.getService().value)); + bytes.write(Ints.toByteArray(arbitraryTransactionData.getServiceInt())); bytes.write((byte) (arbitraryTransactionData.getDataType() == DataType.RAW_DATA ? 1 : 0)); @@ -299,7 +298,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { bytes.write(PaymentTransformer.toBytes(paymentData)); } - bytes.write(Ints.toByteArray(arbitraryTransactionData.getService().value)); + bytes.write(Ints.toByteArray(arbitraryTransactionData.getServiceInt())); bytes.write(Ints.toByteArray(arbitraryTransactionData.getData().length)); diff --git a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java index 69a9ef5b..b966ed2b 100644 --- a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java @@ -4,6 +4,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.ChatTransactionData; @@ -22,11 +23,13 @@ public class ChatTransactionTransformer extends TransactionTransformer { private static final int NONCE_LENGTH = INT_LENGTH; private static final int HAS_RECIPIENT_LENGTH = BOOLEAN_LENGTH; private static final int RECIPIENT_LENGTH = ADDRESS_LENGTH; + private static final int HAS_CHAT_REFERENCE_LENGTH = BOOLEAN_LENGTH; + private static final int CHAT_REFERENCE_LENGTH = SIGNATURE_LENGTH; private static final int DATA_SIZE_LENGTH = INT_LENGTH; private static final int IS_TEXT_LENGTH = BOOLEAN_LENGTH; private static final int IS_ENCRYPTED_LENGTH = BOOLEAN_LENGTH; - private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; + private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH + HAS_CHAT_REFERENCE_LENGTH; protected static final TransactionLayout layout; @@ -77,13 +80,24 @@ public class ChatTransactionTransformer extends TransactionTransformer { long fee = byteBuffer.getLong(); + byte[] chatReference = null; + + if (timestamp >= BlockChain.getInstance().getChatReferenceTimestamp()) { + boolean hasChatReference = byteBuffer.get() != 0; + + if (hasChatReference) { + chatReference = new byte[CHAT_REFERENCE_LENGTH]; + byteBuffer.get(chatReference); + } + } + byte[] signature = new byte[SIGNATURE_LENGTH]; byteBuffer.get(signature); BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature); String sender = Crypto.toAddress(senderPublicKey); - return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, chatReference, data, isText, isEncrypted); } public static int getDataLength(TransactionData transactionData) { @@ -94,6 +108,9 @@ public class ChatTransactionTransformer extends TransactionTransformer { if (chatTransactionData.getRecipient() != null) dataLength += RECIPIENT_LENGTH; + if (chatTransactionData.getChatReference() != null) + dataLength += CHAT_REFERENCE_LENGTH; + return dataLength; } @@ -124,6 +141,16 @@ public class ChatTransactionTransformer extends TransactionTransformer { bytes.write(Longs.toByteArray(chatTransactionData.getFee())); + if (transactionData.getTimestamp() >= BlockChain.getInstance().getChatReferenceTimestamp()) { + // Include chat reference if it's not null + if (chatTransactionData.getChatReference() != null) { + bytes.write((byte) 1); + bytes.write(chatTransactionData.getChatReference()); + } else { + bytes.write((byte) 0); + } + } + if (chatTransactionData.getSignature() != null) bytes.write(chatTransactionData.getSignature()); diff --git a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java index 4c464bee..efd84110 100644 --- a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java +++ b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java @@ -3,11 +3,11 @@ package org.qortal.utils; import org.apache.commons.lang3.ArrayUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.arbitrary.ArbitraryDataFile; -import org.qortal.arbitrary.ArbitraryDataFileChunk; -import org.qortal.arbitrary.ArbitraryDataReader; -import org.qortal.arbitrary.ArbitraryDataResource; +import org.qortal.arbitrary.*; +import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Service; +import org.qortal.data.arbitrary.ArbitraryResourceInfo; +import org.qortal.data.arbitrary.ArbitraryResourceMetadata; import org.qortal.data.arbitrary.ArbitraryResourceStatus; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.TransactionData; @@ -110,13 +110,8 @@ public class ArbitraryTransactionUtils { return false; } - byte[] digest = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - byte[] signature = transactionData.getSignature(); - // Load complete file and chunks - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); return arbitraryDataFile.allChunksExist(); } @@ -126,18 +121,13 @@ public class ArbitraryTransactionUtils { return false; } - byte[] digest = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - byte[] signature = transactionData.getSignature(); - - if (metadataHash == null) { + if (transactionData.getMetadataHash() == null) { // This file doesn't have any metadata/chunks, which means none exist return false; } // Load complete file and chunks - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); return arbitraryDataFile.anyChunksExist(); } @@ -147,12 +137,7 @@ public class ArbitraryTransactionUtils { return 0; } - byte[] digest = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - byte[] signature = transactionData.getSignature(); - - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); // Find the folder containing the files Path parentPath = arbitraryDataFile.getFilePath().getParent(); @@ -180,20 +165,15 @@ public class ArbitraryTransactionUtils { return 0; } - byte[] digest = transactionData.getData(); - byte[] metadataHash = transactionData.getMetadataHash(); - byte[] signature = transactionData.getSignature(); - - if (metadataHash == null) { + if (transactionData.getMetadataHash() == null) { // This file doesn't have any metadata, therefore it has a single (complete) chunk return 1; } // Load complete file and chunks - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); - arbitraryDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); - return arbitraryDataFile.chunkCount() + 1; // +1 for the metadata file + return arbitraryDataFile.fileCount(); } public static boolean isFileRecent(Path filePath, long now, long cleanupAfter) { @@ -243,31 +223,24 @@ public class ArbitraryTransactionUtils { } public static void deleteCompleteFileAndChunks(ArbitraryTransactionData arbitraryTransactionData) throws DataException { - byte[] completeHash = arbitraryTransactionData.getData(); - byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); - byte[] signature = arbitraryTransactionData.getSignature(); - - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(completeHash, signature); - arbitraryDataFile.setMetadataHash(metadataHash); - arbitraryDataFile.deleteAll(); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); + arbitraryDataFile.deleteAll(true); } public static void convertFileToChunks(ArbitraryTransactionData arbitraryTransactionData, long now, long cleanupAfter) throws DataException { - byte[] completeHash = arbitraryTransactionData.getData(); - byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); - byte[] signature = arbitraryTransactionData.getSignature(); - // Find the expected chunk hashes - ArbitraryDataFile expectedDataFile = ArbitraryDataFile.fromHash(completeHash, signature); - expectedDataFile.setMetadataHash(metadataHash); + ArbitraryDataFile expectedDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); - if (metadataHash == null || !expectedDataFile.getMetadataFile().exists()) { + if (arbitraryTransactionData.getMetadataHash() == null || !expectedDataFile.getMetadataFile().exists()) { // We don't have the metadata file, or this transaction doesn't have one - nothing to do return; } + byte[] completeHash = arbitraryTransactionData.getData(); + byte[] signature = arbitraryTransactionData.getSignature(); + // Split the file into chunks - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(completeHash, signature); + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); int chunkCount = arbitraryDataFile.split(ArbitraryDataFile.CHUNK_SIZE); if (chunkCount > 1) { LOGGER.info(String.format("Successfully split %s into %d chunk%s", @@ -426,7 +399,7 @@ public class ArbitraryTransactionUtils { // If "build" has been specified, build the resource before returning its status if (build != null && build == true) { - ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, null); + ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); try { if (!reader.isBuilding()) { reader.loadSynchronously(false); @@ -440,4 +413,41 @@ public class ArbitraryTransactionUtils { return resource.getStatus(false); } + public static 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, ArbitraryDataFile.ResourceIdType.NAME, + resourceInfo.service, resourceInfo.identifier); + ArbitraryResourceStatus status = resource.getStatus(true); + if (status != null) { + resourceInfo.status = status; + } + 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 {}: {}", resourceInfo, e.toString()); + } + } + return updatedResources; + } + + public static 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, ArbitraryDataFile.ResourceIdType.NAME, + resourceInfo.service, resourceInfo.identifier); + ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata(); + ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata, false); + if (resourceMetadata != null) { + resourceInfo.metadata = resourceMetadata; + } + updatedResources.add(resourceInfo); + } + return updatedResources; + } + } diff --git a/src/main/java/org/qortal/utils/BlockArchiveUtils.java b/src/main/java/org/qortal/utils/BlockArchiveUtils.java index 84de1a31..f9ca0d0d 100644 --- a/src/main/java/org/qortal/utils/BlockArchiveUtils.java +++ b/src/main/java/org/qortal/utils/BlockArchiveUtils.java @@ -1,5 +1,7 @@ package org.qortal.utils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; import org.qortal.data.transaction.TransactionData; @@ -12,6 +14,8 @@ import java.util.List; public class BlockArchiveUtils { + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveUtils.class); + /** * importFromArchive *

@@ -21,6 +25,16 @@ public class BlockArchiveUtils { * into the HSQLDB, in order to make it SQL-compatible * again. *

+ * This is only fully compatible with archives that use + * serialization version 1. For version 2 (or above), + * we are unable to import individual AT states as we + * only have a single combined hash, so the use cases + * for this are greatly limited. + *

+ * A version 1 archive should ultimately be rebuildable + * via a resync or reindex from genesis, allowing + * access to this feature once again. + *

* Note: calls discardChanges() and saveChanges(), so * make sure that you commit any existing repository * changes before calling this method. @@ -61,14 +75,24 @@ public class BlockArchiveUtils { repository.getBlockRepository().save(blockInfo.getBlockData()); // Save AT state data hashes - for (ATStateData atStateData : blockInfo.getAtStates()) { - atStateData.setHeight(blockInfo.getBlockData().getHeight()); - repository.getATRepository().save(atStateData); + if (blockInfo.getAtStates() != null) { + for (ATStateData atStateData : blockInfo.getAtStates()) { + atStateData.setHeight(blockInfo.getBlockData().getHeight()); + repository.getATRepository().save(atStateData); + } + } + else { + // We don't have AT state hashes, so we are only importing a partial state. + // This can still be useful to allow orphaning to very old blocks, when we + // need to access other chainstate info (such as balances) at an earlier block. + // In order to do this, the orphan process must be temporarily adjusted to avoid + // orphaning AT states, as it will otherwise fail due to having no previous state. } } catch (DataException e) { repository.discardChanges(); - throw new IllegalStateException("Unable to import blocks from archive"); + LOGGER.info("Unable to import blocks from archive", e); + throw(e); } } repository.saveChanges(); diff --git a/src/main/java/org/qortal/utils/FilesystemUtils.java b/src/main/java/org/qortal/utils/FilesystemUtils.java index 1b3de544..e9921561 100644 --- a/src/main/java/org/qortal/utils/FilesystemUtils.java +++ b/src/main/java/org/qortal/utils/FilesystemUtils.java @@ -228,12 +228,18 @@ public class FilesystemUtils { * @throws IOException */ public static byte[] getSingleFileContents(Path path) throws IOException { + return getSingleFileContents(path, null); + } + + public static byte[] getSingleFileContents(Path path, Integer maxLength) throws IOException { byte[] data = null; // TODO: limit the file size that can be loaded into memory // If the path is a file, read the contents directly if (path.toFile().isFile()) { - data = Files.readAllBytes(path); + int fileSize = (int)path.toFile().length(); + maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize; + data = FilesystemUtils.readFromFile(path.toString(), 0, maxLength); } // Or if it's a directory, only load file contents if there is a single file inside it @@ -241,13 +247,50 @@ public class FilesystemUtils { String[] files = ArrayUtils.removeElement(path.toFile().list(), ".qortal"); if (files.length == 1) { Path filePath = Paths.get(path.toString(), files[0]); - data = Files.readAllBytes(filePath); + if (filePath.toFile().isFile()) { + int fileSize = (int)filePath.toFile().length(); + maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize; + data = FilesystemUtils.readFromFile(filePath.toString(), 0, maxLength); + } } } return data; } + /** + * isSingleFileResource + * Returns true if the path points to a file, or a + * directory containing a single file only. + * + * @param path to file or directory + * @param excludeQortalDirectory - if true, a directory containing a single file and a .qortal directory is considered a single file resource + * @return + * @throws IOException + */ + public static boolean isSingleFileResource(Path path, boolean excludeQortalDirectory) { + // If the path is a file, read the contents directly + if (path.toFile().isFile()) { + return true; + } + + // Or if it's a directory, only load file contents if there is a single file inside it + else if (path.toFile().isDirectory()) { + String[] files = path.toFile().list(); + if (excludeQortalDirectory) { + files = ArrayUtils.removeElement(files, ".qortal"); + } + if (files.length == 1) { + Path filePath = Paths.get(path.toString(), files[0]); + if (filePath.toFile().isFile()) { + return true; + } + } + } + + return false; + } + public static byte[] readFromFile(String filePath, long position, int size) throws IOException { RandomAccessFile file = new RandomAccessFile(filePath, "r"); file.seek(position); diff --git a/src/main/java/org/qortal/utils/ListUtils.java b/src/main/java/org/qortal/utils/ListUtils.java new file mode 100644 index 00000000..73c847d2 --- /dev/null +++ b/src/main/java/org/qortal/utils/ListUtils.java @@ -0,0 +1,38 @@ +package org.qortal.utils; + +import org.qortal.list.ResourceListManager; + +import java.util.List; + +public class ListUtils { + + /* Blocking */ + + public static List blockedNames() { + return ResourceListManager.getInstance().getStringsInListsWithPrefix("blockedNames"); + } + + public static boolean isNameBlocked(String name) { + return ResourceListManager.getInstance().listWithPrefixContains("blockedNames", name, false); + } + + public static boolean isAddressBlocked(String address) { + return ResourceListManager.getInstance().listWithPrefixContains("blockedAddresses", address, true); + } + + + /* Following */ + + public static List followedNames() { + return ResourceListManager.getInstance().getStringsInListsWithPrefix("followedNames"); + } + + public static boolean isFollowingName(String name) { + return ResourceListManager.getInstance().listWithPrefixContains("followedNames", name, false); + } + + public static int followedNamesCount() { + return ListUtils.followedNames().size(); + } + +} diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index fad81ab5..9a26c99e 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -3,8 +3,12 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.001", + "unitFees": [ + { "timestamp": 0, "fee": "0.001" }, + { "timestamp": 1692118800000, "fee": "0.01" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.001" }, { "timestamp": 1645372800000, "fee": "5" }, { "timestamp": 1651420800000, "fee": "1.25" } ], @@ -24,7 +28,8 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, - "onlineAccountsMemoryPoWTimestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, + "mempowTransactionUpdatesTimestamp": 1693558800000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, @@ -80,8 +85,17 @@ "calcChainWeightTimestamp": 1620579600000, "transactionV5Timestamp": 1642176000000, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 1655222400000 + "disableReferenceTimestamp": 1655222400000, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 1092000, + "selfSponsorshipAlgoV1Height": 1092400, + "feeValidationFixTimestamp": 1671918000000, + "chatReferenceTimestamp": 1674316800000, + "arbitraryOptionalFeeTimestamp": 1680278400000 }, + "checkpoints": [ + { "height": 1136300, "signature": "3BbwawEF2uN8Ni5ofpJXkukoU8ctAPxYoFB7whq9pKfBnjfZcpfEJT4R95NvBDoTP8WDyWvsUvbfHbcr9qSZuYpSKZjUQTvdFf6eqznHGEwhZApWfvXu6zjGCxYCp65F4jsVYYJjkzbjmkCg5WAwN5voudngA23kMK6PpTNygapCzXt" } + ], "genesisInfo": { "version": 4, "timestamp": "1593450000000", diff --git a/src/main/resources/i18n/ApiError_jp.properties b/src/main/resources/i18n/ApiError_jp.properties new file mode 100644 index 00000000..603914cb --- /dev/null +++ b/src/main/resources/i18n/ApiError_jp.properties @@ -0,0 +1,83 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +# "localeLang": "jp", + +### Common ### +JSON = JSON メッセージの解析に失敗しました + +INSUFFICIENT_BALANCE = 残高不足 + +UNAUTHORIZED = APIコール未承認 + +REPOSITORY_ISSUE = リポジトリエラー + +NON_PRODUCTION = この APIコールはプロダクションシステムでは許可されていません + +BLOCKCHAIN_NEEDS_SYNC = ブロックチェーンをまず同期する必要があります + +NO_TIME_SYNC = 時刻が未同期 + +### Validation ### +INVALID_SIGNATURE = 無効な署名 + +INVALID_ADDRESS = 無効なアドレス + +INVALID_PUBLIC_KEY = 無効な公開鍵 + +INVALID_DATA = 無効なデータ + +INVALID_NETWORK_ADDRESS = 無効なネットワーク アドレス + +ADDRESS_UNKNOWN = 不明なアカウントアドレス + +INVALID_CRITERIA = 無効な検索条件 + +INVALID_REFERENCE = 無効な参照 + +TRANSFORMATION_ERROR = JSONをトランザクションに変換出来ませんでした + +INVALID_PRIVATE_KEY = 無効な秘密鍵 + +INVALID_HEIGHT = 無効なブロック高 + +CANNOT_MINT = アカウントはミント出来ません + +### Blocks ### +BLOCK_UNKNOWN = 不明なブロック + +### Transactions ### +TRANSACTION_UNKNOWN = 不明なトランザクション + +PUBLIC_KEY_NOT_FOUND = 公開鍵が見つかりません + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = 無効なトランザクション: %s (%s) + +### Naming ### +NAME_UNKNOWN = 不明な名前 + +### Asset ### +INVALID_ASSET_ID = 無効なアセット ID + +INVALID_ORDER_ID = 無効なアセット注文 ID + +ORDER_UNKNOWN = 不明なアセット注文 ID + +### Groups ### +GROUP_UNKNOWN = 不明なグループ + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = 外部ブロックチェーンまたはElectrumXネットワークの問題 + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = 外部ブロックチェーンの残高が不足しています + +FOREIGN_BLOCKCHAIN_TOO_SOON = 外部ブロックチェーン トランザクションのブロードキャストが時期尚早 (ロックタイム/ブロック時間の中央値) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = 注文金額が低すぎます + +### Data ### +FILE_NOT_FOUND = ファイルが見つかりません + +NO_REPLY = ピアが制限時間内に応答しませんでした diff --git a/src/main/resources/i18n/ApiError_pl.properties b/src/main/resources/i18n/ApiError_pl.properties new file mode 100644 index 00000000..fcb6191c --- /dev/null +++ b/src/main/resources/i18n/ApiError_pl.properties @@ -0,0 +1,83 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +# "localeLang": "pl", + +### Common ### +JSON = nie udało się przetworzyć wiadomości JSON + +INSUFFICIENT_BALANCE = niedostateczne środki + +UNAUTHORIZED = nieautoryzowane połączenie API + +REPOSITORY_ISSUE = błąd repozytorium + +NON_PRODUCTION = to wywołanie API nie jest dozwolone dla systemów produkcyjnych + +BLOCKCHAIN_NEEDS_SYNC = blockchain musi się najpierw zsynchronizować + +NO_TIME_SYNC = zegar się jeszcze nie zsynchronizował + +### Validation ### +INVALID_SIGNATURE = nieprawidłowa sygnatura + +INVALID_ADDRESS = nieprawidłowy adres + +INVALID_PUBLIC_KEY = nieprawidłowy klucz publiczny + +INVALID_DATA = nieprawidłowe dane + +INVALID_NETWORK_ADDRESS = nieprawidłowy adres sieci + +ADDRESS_UNKNOWN = nieznany adres konta + +INVALID_CRITERIA = nieprawidłowe kryteria wyszukiwania + +INVALID_REFERENCE = nieprawidłowe skierowanie + +TRANSFORMATION_ERROR = nie udało się przekształcić JSON w transakcję + +INVALID_PRIVATE_KEY = klucz prywatny jest niepoprawny + +INVALID_HEIGHT = nieprawidłowa wysokość bloku + +CANNOT_MINT = konto nie możne bić monet + +### Blocks ### +BLOCK_UNKNOWN = blok nieznany + +### Transactions ### +TRANSACTION_UNKNOWN = nieznana transakcja + +PUBLIC_KEY_NOT_FOUND = nie znaleziono klucza publicznego + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = transakcja nieważna: %s (%s) + +### Naming ### +NAME_UNKNOWN = nazwa nieznana + +### Asset ### +INVALID_ASSET_ID = nieprawidłowy identyfikator aktywy + +INVALID_ORDER_ID = nieprawidłowy identyfikator zlecenia aktywy + +ORDER_UNKNOWN = nieznany identyfikator zlecenia aktywy + +### Groups ### +GROUP_UNKNOWN = nieznana grupa + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = obcy blockchain lub problem z siecią ElectrumX + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = niewystarczające środki na obcym blockchainie + +FOREIGN_BLOCKCHAIN_TOO_SOON = zbyt wczesne nadawanie transakcji na obcym blockchainie (okres karencji/średni czas bloku) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = zbyt niska kwota zlecenia + +### Data ### +FILE_NOT_FOUND = plik nie został znaleziony + +NO_REPLY = peer nie odpowiedział w wyznaczonym czasie diff --git a/src/main/resources/i18n/ApiError_ru.properties b/src/main/resources/i18n/ApiError_ru.properties index 52580ac8..1367f29b 100644 --- a/src/main/resources/i18n/ApiError_ru.properties +++ b/src/main/resources/i18n/ApiError_ru.properties @@ -16,7 +16,7 @@ NON_PRODUCTION = этот вызов API не разрешен для произ BLOCKCHAIN_NEEDS_SYNC = блокчейн должен сначала синхронизироваться -NO_TIME_SYNC = пока нет синхронизации часов +NO_TIME_SYNC = время не синхронизировано ### Validation ### INVALID_SIGNATURE = недействительная подпись @@ -72,7 +72,7 @@ FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = проблема с внешним блокч FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = недостаточный баланс на внешнем блокчейне -FOREIGN_BLOCKCHAIN_TOO_SOON = слишком рано для трансляции транзакции во внений блокчей (время блокировки/среднее время блока) +FOREIGN_BLOCKCHAIN_TOO_SOON = слишком рано для трансляции транзакции во внешний блокчей (время блокировки/среднее время блока) ### Trade Portal ### ORDER_SIZE_TOO_SMALL = слишком маленькая сумма ордера @@ -80,4 +80,4 @@ ORDER_SIZE_TOO_SMALL = слишком маленькая сумма ордера ### Data ### FILE_NOT_FOUND = файл не найден -NO_REPLY = узел не ответил данными +NO_REPLY = нет ответа diff --git a/src/main/resources/i18n/SysTray_de.properties b/src/main/resources/i18n/SysTray_de.properties index f6dbc740..c92130f1 100644 --- a/src/main/resources/i18n/SysTray_de.properties +++ b/src/main/resources/i18n/SysTray_de.properties @@ -5,9 +5,11 @@ APPLYING_UPDATE_AND_RESTARTING = Automatisches Update anwenden und neu starten.. AUTO_UPDATE = Automatisches Update -BLOCK_HEIGHT = height +BLOCK_HEIGHT = Blockhöhe -BUILD_VERSION = Build-Version +BLOCKS_REMAINING = blocks remaining + +BUILD_VERSION = Entwicklungs-Version CHECK_TIME_ACCURACY = Prüfe Zeitgenauigkeit @@ -21,7 +23,7 @@ CREATING_BACKUP_OF_DB_FILES = Erstelle Backup von Datenbank Dateien... DB_BACKUP = Datenbank Backup -DB_CHECKPOINT = Datenbank Kontrollpunkt +DB_CHECKPOINT = Datenbank Check DB_MAINTENANCE = Datenbank Instandhaltung diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index 204f0df2..39940be0 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Auto Update BLOCK_HEIGHT = height +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Build version CHECK_TIME_ACCURACY = Check time accuracy diff --git a/src/main/resources/i18n/SysTray_es.properties b/src/main/resources/i18n/SysTray_es.properties index d4b931d4..36cbb22c 100644 --- a/src/main/resources/i18n/SysTray_es.properties +++ b/src/main/resources/i18n/SysTray_es.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Actualización automática BLOCK_HEIGHT = altura +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versión de compilación CHECK_TIME_ACCURACY = Comprobar la precisión del tiempo diff --git a/src/main/resources/i18n/SysTray_fi.properties b/src/main/resources/i18n/SysTray_fi.properties index bc787715..4038d615 100644 --- a/src/main/resources/i18n/SysTray_fi.properties +++ b/src/main/resources/i18n/SysTray_fi.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automaattinen päivitys BLOCK_HEIGHT = korkeus +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versio CHECK_TIME_ACCURACY = Tarkista ajan tarkkuus diff --git a/src/main/resources/i18n/SysTray_fr.properties b/src/main/resources/i18n/SysTray_fr.properties index 6e60713c..2e376842 100644 --- a/src/main/resources/i18n/SysTray_fr.properties +++ b/src/main/resources/i18n/SysTray_fr.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Mise à jour automatique BLOCK_HEIGHT = hauteur +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Numéro de version CHECK_TIME_ACCURACY = Vérifier l'heure diff --git a/src/main/resources/i18n/SysTray_hu.properties b/src/main/resources/i18n/SysTray_hu.properties index 9bc51ff5..74ab21ac 100644 --- a/src/main/resources/i18n/SysTray_hu.properties +++ b/src/main/resources/i18n/SysTray_hu.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatikus Frissítés BLOCK_HEIGHT = blokkmagasság +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Verzió CHECK_TIME_ACCURACY = Óra pontosságának ellenőrzése diff --git a/src/main/resources/i18n/SysTray_it.properties b/src/main/resources/i18n/SysTray_it.properties index bf61cc46..d966d825 100644 --- a/src/main/resources/i18n/SysTray_it.properties +++ b/src/main/resources/i18n/SysTray_it.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Aggiornamento automatico BLOCK_HEIGHT = altezza +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versione CHECK_TIME_ACCURACY = Controlla la precisione dell'ora diff --git a/src/main/resources/i18n/SysTray_jp.properties b/src/main/resources/i18n/SysTray_jp.properties new file mode 100644 index 00000000..c4cccb5b --- /dev/null +++ b/src/main/resources/i18n/SysTray_jp.properties @@ -0,0 +1,48 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu # Japanese translation by R M 2023 + +APPLYING_UPDATE_AND_RESTARTING = 自動更新を適用して再起動しています... + +AUTO_UPDATE = 自動更新 + +BLOCK_HEIGHT = ブロック高 + +BLOCKS_REMAINING = 残りのブロック + +BUILD_VERSION = ビルドバージョン + +CHECK_TIME_ACCURACY = 時刻の精度を確認 + +CONNECTING = 接続中 + +CONNECTION = 接続 + +CONNECTIONS = 接続 + +CREATING_BACKUP_OF_DB_FILES = データベース ファイルのバックアップを作成中... + +DB_BACKUP = データベースのバックアップ + +DB_CHECKPOINT = データベースのチェックポイント + +DB_MAINTENANCE = データベースのメンテナンス + +EXIT = 終了 + +LITE_NODE = ライトノード + +MINTING_DISABLED = ミント一時中止中 + +MINTING_ENABLED = \u2714 ミント + +OPEN_UI = UIを開く + +PERFORMING_DB_CHECKPOINT = コミットされていないデータベースの変更を保存中... + +PERFORMING_DB_MAINTENANCE = 定期メンテナンスを実行中... + +SYNCHRONIZE_CLOCK = 時刻を同期 + +SYNCHRONIZING_BLOCKCHAIN = ブロックチェーンを同期中 + +SYNCHRONIZING_CLOCK = 時刻を同期中 diff --git a/src/main/resources/i18n/SysTray_ko.properties b/src/main/resources/i18n/SysTray_ko.properties index 9773a54f..dc6cb69b 100644 --- a/src/main/resources/i18n/SysTray_ko.properties +++ b/src/main/resources/i18n/SysTray_ko.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 자동 업데이트 BLOCK_HEIGHT = 높이 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 빌드 버전 CHECK_TIME_ACCURACY = 시간 정확도 점검 diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties index 8a4f112b..c2acb7ce 100644 --- a/src/main/resources/i18n/SysTray_nl.properties +++ b/src/main/resources/i18n/SysTray_nl.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatische Update BLOCK_HEIGHT = Block hoogte +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versie nummer CHECK_TIME_ACCURACY = Controleer accuraatheid van de tijd diff --git a/src/main/resources/i18n/SysTray_pl.properties b/src/main/resources/i18n/SysTray_pl.properties new file mode 100644 index 00000000..84740da0 --- /dev/null +++ b/src/main/resources/i18n/SysTray_pl.properties @@ -0,0 +1,46 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +APPLYING_UPDATE_AND_RESTARTING = Zastosowanie automatycznej aktualizacji i ponowne uruchomienie... + +AUTO_UPDATE = Automatyczna aktualizacja + +BLOCK_HEIGHT = wysokość + +BUILD_VERSION = Wersja kompilacji + +CHECK_TIME_ACCURACY = Sprawdz dokładność czasu + +CONNECTING = Łączenie + +CONNECTION = połączenie + +CONNECTIONS = połączenia + +CREATING_BACKUP_OF_DB_FILES = Tworzenie kopii zapasowej plików bazy danych... + +DB_BACKUP = Kopia zapasowa bazy danych + +DB_CHECKPOINT = Punkt kontrolny bazy danych... + +DB_MAINTENANCE = Konserwacja bazy danych + +EXIT = Zakończ + +LITE_NODE = Lite node + +MINTING_DISABLED = Mennica zamknięta + +MINTING_ENABLED = \u2714 Mennica aktywna + +OPEN_UI = Otwórz interfejs użytkownika + +PERFORMING_DB_CHECKPOINT = Zapisywanie niezaksięgowanych zmian w bazie danych... + +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + +SYNCHRONIZE_CLOCK = Synchronizuj zegar + +SYNCHRONIZING_BLOCKCHAIN = Synchronizacja + +SYNCHRONIZING_CLOCK = Synchronizacja zegara diff --git a/src/main/resources/i18n/SysTray_ro.properties b/src/main/resources/i18n/SysTray_ro.properties index 0e1aa6c6..4130bbcb 100644 --- a/src/main/resources/i18n/SysTray_ro.properties +++ b/src/main/resources/i18n/SysTray_ro.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Actualizare automata BLOCK_HEIGHT = dimensiune +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = versiunea compilatiei CHECK_TIME_ACCURACY = verificare exactitate ora diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index fc3d8648..c8615f73 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -1,12 +1,14 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -APPLYING_UPDATE_AND_RESTARTING = Применение автоматического обновления и перезапуска... +APPLYING_UPDATE_AND_RESTARTING = Применение автоматического обновления и перезапуск... AUTO_UPDATE = Автоматическое обновление BLOCK_HEIGHT = Высота блока +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Версия сборки CHECK_TIME_ACCURACY = Проверка точного времени diff --git a/src/main/resources/i18n/SysTray_sv.properties b/src/main/resources/i18n/SysTray_sv.properties index 0e74337b..96f291b5 100644 --- a/src/main/resources/i18n/SysTray_sv.properties +++ b/src/main/resources/i18n/SysTray_sv.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatisk uppdatering BLOCK_HEIGHT = höjd +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Byggversion CHECK_TIME_ACCURACY = Kontrollera tidens noggrannhet diff --git a/src/main/resources/i18n/SysTray_zh_CN.properties b/src/main/resources/i18n/SysTray_zh_CN.properties index c103d24b..d6848a7c 100644 --- a/src/main/resources/i18n/SysTray_zh_CN.properties +++ b/src/main/resources/i18n/SysTray_zh_CN.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 自动更新 BLOCK_HEIGHT = 区块高度 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 版本 CHECK_TIME_ACCURACY = 检查时间准确性 diff --git a/src/main/resources/i18n/SysTray_zh_TW.properties b/src/main/resources/i18n/SysTray_zh_TW.properties index 5e6ccc3e..eabdbb63 100644 --- a/src/main/resources/i18n/SysTray_zh_TW.properties +++ b/src/main/resources/i18n/SysTray_zh_TW.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 自動更新 BLOCK_HEIGHT = 區塊高度 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 版本 CHECK_TIME_ACCURACY = 檢查時間準確性 diff --git a/src/main/resources/i18n/TransactionValidity_jp.properties b/src/main/resources/i18n/TransactionValidity_jp.properties new file mode 100644 index 00000000..9540372a --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_jp.properties @@ -0,0 +1,195 @@ +# + +ACCOUNT_ALREADY_EXISTS = 既にアカウントは存在します + +ACCOUNT_CANNOT_REWARD_SHARE = アカウントは報酬シェアが出来ません + +ADDRESS_ABOVE_RATE_LIMIT = アドレスが指定されたレート制限に達しました + +ADDRESS_BLOCKED = このアドレスはブロックされています + +ALREADY_GROUP_ADMIN = 既ににグループ管理者です + +ALREADY_GROUP_MEMBER = 既にグループメンバーです + +ALREADY_VOTED_FOR_THAT_OPTION = 既にそのオプションに投票しています + +ASSET_ALREADY_EXISTS = 既にアセットは存在します + +ASSET_DOES_NOT_EXIST = アセットが存在しません + +ASSET_DOES_NOT_MATCH_AT = アセットがATのアセットと一致しません + +ASSET_NOT_SPENDABLE = 資産が使用不可です + +AT_ALREADY_EXISTS = 既にATが存在します + +AT_IS_FINISHED = ATが終了しました + +AT_UNKNOWN = 不明なAT + +BAN_EXISTS = 既にバンされてます + +BAN_UNKNOWN = 不明なバン + +BANNED_FROM_GROUP = グループからのバンされています + +BUYER_ALREADY_OWNER = 既に購入者が所有者です + +CLOCK_NOT_SYNCED = 時刻が未同期 + +DUPLICATE_MESSAGE = このアドレスは重複メッセージを送信しました + +DUPLICATE_OPTION = 重複したオプション + +GROUP_ALREADY_EXISTS = 既にグループは存在します + +GROUP_APPROVAL_DECIDED = 既にグループの承認は決定されています + +GROUP_APPROVAL_NOT_REQUIRED = グループ承認が不必要 + +GROUP_DOES_NOT_EXIST = グループが存在しません + +GROUP_ID_MISMATCH = グループ ID が不一致 + +GROUP_OWNER_CANNOT_LEAVE = グループ所有者はグループを退会出来ません + +HAVE_EQUALS_WANT = 持っている資産は欲しい資産と同じです + +INCORRECT_NONCE = 不正な PoW ナンス + +INSUFFICIENT_FEE = 手数料が不十分です + +INVALID_ADDRESS = 無効なアドレス + +INVALID_AMOUNT = 無効な金額 + +INVALID_ASSET_OWNER = 無効なアセット所有者 + +INVALID_AT_TRANSACTION = 無効なATトランザクション + +INVALID_AT_TYPE_LENGTH = 無効なATの「タイプ」の長さです + +INVALID_BUT_OK = 無効だがOK + +INVALID_CREATION_BYTES = 無効な作成バイト数 + +INVALID_DATA_LENGTH = 無効なデータ長 + +INVALID_DESCRIPTION_LENGTH = 無効な概要の長さ + +INVALID_GROUP_APPROVAL_THRESHOLD = 無効なグループ承認のしきい値 + +INVALID_GROUP_BLOCK_DELAY = 無効なグループ承認のブロック遅延 + +INVALID_GROUP_ID = 無効なグループ ID + +INVALID_GROUP_OWNER = 無効なグループ所有者 + +INVALID_LIFETIME = 無効な有効期間 + +INVALID_NAME_LENGTH = 無効な名前の長さです + +INVALID_NAME_OWNER = 無効な名前の所有者 + +INVALID_OPTION_LENGTH = 無効なオプションの長さ + +INVALID_OPTIONS_COUNT = 無効なオプションの数 + +INVALID_ORDER_CREATOR = 無効な注文作成者 + +INVALID_PAYMENTS_COUNT = 無効な入出金数 + +INVALID_PUBLIC_KEY = 無効な公開鍵 + +INVALID_QUANTITY = 無効な数量 + +INVALID_REFERENCE = 無効な参照 + +INVALID_RETURN = 無効な返品 + +INVALID_REWARD_SHARE_PERCENT = 無効な報酬シェア率 + +INVALID_SELLER = 無効な販売者 + +INVALID_TAGS_LENGTH = 無効な「タグ」の長さ + +INVALID_TIMESTAMP_SIGNATURE = 無効なタイムスタンプ署名 + +INVALID_TX_GROUP_ID = 無効なトランザクション グループ ID + +INVALID_VALUE_LENGTH = 無効な「値」の長さ + +INVITE_UNKNOWN = 不明なグループ招待 + +JOIN_REQUEST_EXISTS = 既にグループ参加リクエストが存在します + +MAXIMUM_REWARD_SHARES = 既にこのアカウントの報酬シェアは最大です + +MISSING_CREATOR = 作成者が見つかりません + +MULTIPLE_NAMES_FORBIDDEN = アカウントごとに複数の登録名は禁止されています + +NAME_ALREADY_FOR_SALE = 既に名前は販売中です + +NAME_ALREADY_REGISTERED = 既に名前は登録されています + +NAME_BLOCKED = この名前はブロックされています + +NAME_DOES_NOT_EXIST = 名前は存在しません + +NAME_NOT_FOR_SALE = 名前は非売品です + +NAME_NOT_NORMALIZED = 名前は Unicode の「正規化」形式ではありません + +NEGATIVE_AMOUNT = 無効な/負の金額 + +NEGATIVE_FEE = 無効な/負の料金 + +NEGATIVE_PRICE = 無効な/負の価格 + +NO_BALANCE = 残高が不足しています + +NO_BLOCKCHAIN_LOCK = ノードのブロックチェーンは現在ビジーです + +NO_FLAG_PERMISSION = アカウントにはその権限がありません + +NOT_GROUP_ADMIN = アカウントはグループ管理者ではありません + +NOT_GROUP_MEMBER = アカウントはグループメンバーではありません + +NOT_MINTING_ACCOUNT = アカウントはミント出来ません + +NOT_YET_RELEASED = 機能はまだリリースされていません + +OK = OK + +ORDER_ALREADY_CLOSED = 既に資産取引注文は終了しています + +ORDER_DOES_NOT_EXIST = 資産取引注文が存在しません + +POLL_ALREADY_EXISTS = 既に投票は存在します + +POLL_DOES_NOT_EXIST = 投票は存在しません + +POLL_OPTION_DOES_NOT_EXIST = 投票オプションが存在しません + +PUBLIC_KEY_UNKNOWN = 不明な公開鍵 + +REWARD_SHARE_UNKNOWN = 不明な報酬シェア + +SELF_SHARE_EXISTS = 既に自己シェア(報酬シェア)が存在します + +TIMESTAMP_TOO_NEW = タイムスタンプが新しすぎます + +TIMESTAMP_TOO_OLD = タイムスタンプが古すぎます + +TOO_MANY_UNCONFIRMED = アカウントに保留中の未承認トランザクションが多すぎます + +TRANSACTION_ALREADY_CONFIRMED = 既にトランザクションは承認されています + +TRANSACTION_ALREADY_EXISTS = 既にトランザクションは存在します + +TRANSACTION_UNKNOWN = 不明なトランザクション + +TX_GROUP_ID_MISMATCH = トランザクションのグループIDが一致しません diff --git a/src/main/resources/i18n/TransactionValidity_pl.properties b/src/main/resources/i18n/TransactionValidity_pl.properties new file mode 100644 index 00000000..bcdceb6e --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_pl.properties @@ -0,0 +1,196 @@ +# + +ACCOUNT_ALREADY_EXISTS = konto już istnieje + +ACCOUNT_CANNOT_REWARD_SHARE = konto nie może udostępniać nagród + +ADDRESS_ABOVE_RATE_LIMIT = adres osiągnął określony limit stawki + +ADDRESS_BLOCKED = ten adres jest zablokowany + +ALREADY_GROUP_ADMIN = już adminem grupy + +ALREADY_GROUP_MEMBER = już członkiem grupy + +ALREADY_VOTED_FOR_THAT_OPTION = już zagłosowano na ta opcje + +ASSET_ALREADY_EXISTS = aktywa już istnieje + +ASSET_DOES_NOT_EXIST = aktywa nie istnieje + +ASSET_DOES_NOT_MATCH_AT = aktywa nie pasuje do aktywy AT + +ASSET_NOT_SPENDABLE = aktywa nie jest rozporządzalna + +AT_ALREADY_EXISTS = AT już istnieje + +AT_IS_FINISHED = AT zakończył + +AT_UNKNOWN = AT nieznany + +BAN_EXISTS = ban już istnieje + +BAN_UNKNOWN = ban nieznany + +BANNED_FROM_GROUP = zbanowany z grupy + +BUYER_ALREADY_OWNER = kupca jest już właścicielem + +CLOCK_NOT_SYNCED = zegar nie zsynchronizowany + +DUPLICATE_MESSAGE = adres wysłał duplikat wiadomości + +DUPLICATE_OPTION = duplikat opcji + +GROUP_ALREADY_EXISTS = grupa już istnieje + +GROUP_APPROVAL_DECIDED = zatwierdzenie grupy już zdecydowano + +GROUP_APPROVAL_NOT_REQUIRED = zatwierdzenie grupy nie jest wymagane + +GROUP_DOES_NOT_EXIST = grupa nie istnieje + +GROUP_ID_MISMATCH = niedopasowanie identyfikatora grupy + +GROUP_OWNER_CANNOT_LEAVE = właściciel grupy nie może opuścić grupy + +HAVE_EQUALS_WANT = posiadana aktywa równa się chcianej aktywie + +INCORRECT_NONCE = nieprawidłowy nonce PoW + +INSUFFICIENT_FEE = niewystarczająca opłata + +INVALID_ADDRESS = nieprawidłowy adres + +INVALID_AMOUNT = nieprawidłowa kwota + +INVALID_ASSET_OWNER = nieprawidłowy właściciel aktywów + +INVALID_AT_TRANSACTION = nieważna transakcja AT + +INVALID_AT_TYPE_LENGTH = nieprawidłowa długość typu AT + +INVALID_BUT_OK = nieważne, ale OK + +INVALID_CREATION_BYTES = nieprawidłowe bajty tworzenia + +INVALID_DATA_LENGTH = nieprawidłowa długość danych + +INVALID_DESCRIPTION_LENGTH = nieprawidłowa długość opisu + +INVALID_GROUP_APPROVAL_THRESHOLD = nieprawidłowy próg zatwierdzenia grupy + +INVALID_GROUP_BLOCK_DELAY = nieprawidłowe opóźnienie bloku zatwierdzenia grupy + +INVALID_GROUP_ID = nieprawidłowy identyfikator grupy + +INVALID_GROUP_OWNER = nieprawidłowy właściciel grupy + +INVALID_LIFETIME = nieprawidłowy czas istnienia + +INVALID_NAME_LENGTH = nieprawidłowa długość nazwy + +INVALID_NAME_OWNER = nieprawidłowy właściciel nazwy + +INVALID_OPTION_LENGTH = nieprawidłowa długość opcji + +INVALID_OPTIONS_COUNT = nieprawidłowa liczba opcji + +INVALID_ORDER_CREATOR = nieprawidłowy twórca zlecenia + +INVALID_PAYMENTS_COUNT = nieprawidłowa liczba płatności + +INVALID_PUBLIC_KEY = nieprawidłowy klucz publiczny + +INVALID_QUANTITY = nieprawidłowa ilość + +INVALID_REFERENCE = nieprawidłowe skierowanie + +INVALID_RETURN = nieprawidłowy zwrot + +INVALID_REWARD_SHARE_PERCENT = nieprawidłowy procent udziału w nagrodzie + +INVALID_SELLER = nieprawidłowy sprzedawca + +INVALID_TAGS_LENGTH = nieprawidłowa długość tagów + +INVALID_TIMESTAMP_SIGNATURE = nieprawidłowa sygnatura znacznika czasu + +INVALID_TX_GROUP_ID = nieprawidłowy identyfikator grupy transakcji + +INVALID_VALUE_LENGTH = nieprawidłowa długość wartości + +INVITE_UNKNOWN = zaproszenie do grupy nieznane + +JOIN_REQUEST_EXISTS = wniosek o dołączenie do grupy już istnieje + +MAXIMUM_REWARD_SHARES = osiągnięto już maksymalną liczbę udziałów w nagrodzie dla tego konta + +MISSING_CREATOR = brak twórcy + +MULTIPLE_NAMES_FORBIDDEN = zabronione jest używanie wielu nazw na jednym koncie + +NAME_ALREADY_FOR_SALE = nazwa już wystawiona na sprzedaż + +NAME_ALREADY_REGISTERED = nazwa już zarejestrowana + +NAME_BLOCKED = ta nazwa jest zablokowana + +NAME_DOES_NOT_EXIST = nazwa nie istnieje + +NAME_NOT_FOR_SALE = nazwa nie jest przeznaczona do sprzedaży + +NAME_NOT_NORMALIZED = nazwa nie jest w formie 'znormalizowanej' Unicode + +NEGATIVE_AMOUNT = nieprawidłowa/ujemna kwota + +NEGATIVE_FEE = nieprawidłowa/ujemna opłata + +NEGATIVE_PRICE = nieprawidłowa/ujemna cena + +NO_BALANCE = niewystarczające środki + +NO_BLOCKCHAIN_LOCK = węzeł blockchain jest obecnie zajęty + +NO_FLAG_PERMISSION = konto nie ma tego uprawnienia + +NOT_GROUP_ADMIN = konto nie jest adminem grupy + +NOT_GROUP_MEMBER = konto nie jest członkiem grupy + +NOT_MINTING_ACCOUNT = konto nie może bić monet + +NOT_YET_RELEASED = funkcja nie została jeszcze udostępniona + +OK = OK + +ORDER_ALREADY_CLOSED = zlecenie handlu aktywami jest już zakończone + +ORDER_DOES_NOT_EXIST = zlecenie sprzedaży aktywów nie istnieje + +POLL_ALREADY_EXISTS = ankieta już istnieje + +POLL_DOES_NOT_EXIST = ankieta nie istnieje + +POLL_OPTION_DOES_NOT_EXIST = opcja ankiety nie istnieje + +PUBLIC_KEY_UNKNOWN = klucz publiczny nieznany + +REWARD_SHARE_UNKNOWN = nieznany udział w nagrodzie + +SELF_SHARE_EXISTS = samoudział (udział w nagrodzie) już istnieje + +TIMESTAMP_TOO_NEW = zbyt nowy znacznik czasu + +TIMESTAMP_TOO_OLD = zbyt stary znacznik czasu + +TOO_MANY_UNCONFIRMED = rachunek ma zbyt wiele niepotwierdzonych transakcji w toku + +TRANSACTION_ALREADY_CONFIRMED = transakcja została już potwierdzona + +TRANSACTION_ALREADY_EXISTS = transakcja już istnieje + +TRANSACTION_UNKNOWN = transakcja nieznana + +TX_GROUP_ID_MISMATCH = niezgodność ID grupy transakcji + diff --git a/src/main/resources/loading/index.html b/src/main/resources/loading/index.html index a828e04e..574645cc 100644 --- a/src/main/resources/loading/index.html +++ b/src/main/resources/loading/index.html @@ -43,8 +43,9 @@ var host = location.protocol + '//' + location.host; var service = "%%SERVICE%%" var name = "%%NAME%%" + var identifier = "%%IDENTIFIER%%" - var url = host + '/arbitrary/resource/status/' + service + '/' + name + '?build=true'; + var url = host + '/arbitrary/resource/status/' + service + '/' + name + '/' + identifier + '?build=true'; var textStatus = "Loading..."; var textProgress = ""; var retryInterval = 2500; @@ -74,18 +75,18 @@ } else if (status.id == "BUILDING") { textStatus = status.description; - retryInterval = 1000; + retryInterval = 2000; } else if (status.id == "BUILD_FAILED") { textStatus = status.description; } else if (status.id == "NOT_STARTED") { textStatus = status.description; - retryInterval = 1000; + retryInterval = 2000; } else if (status.id == "DOWNLOADING") { textStatus = status.description; - retryInterval = 1000; + retryInterval = 2000; } else if (status.id == "MISSING_DATA") { textStatus = status.description; @@ -96,8 +97,14 @@ else if (status.id == "DOWNLOADED") { textStatus = status.description; } + else if (status.id == "NOT_PUBLISHED") { + document.getElementById("title").innerHTML = "File not found"; + document.getElementById("description").innerHTML = ""; + document.getElementById("c").style.opacity = "0.5"; + textStatus = status.description; + } - if (status.localChunkCount != null && status.totalChunkCount != null) { + if (status.localChunkCount != null && status.totalChunkCount != null && status.totalChunkCount > 0) { textProgress = "Files downloaded: " + status.localChunkCount + " / " + status.totalChunkCount; } @@ -275,8 +282,8 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI

-

Loading

-

+

Loading

+

Files are being retrieved from the Qortal Data Network. This page will refresh automatically when the content becomes available.

diff --git a/src/main/resources/q-apps/q-apps-gateway.js b/src/main/resources/q-apps/q-apps-gateway.js new file mode 100644 index 00000000..d5028dca --- /dev/null +++ b/src/main/resources/q-apps/q-apps-gateway.js @@ -0,0 +1,76 @@ +console.log("Gateway mode"); + +function qdnGatewayShowModal(message) { + const modalElementId = "qdnGatewayModal"; + + if (document.getElementById(modalElementId) != null) { + document.body.removeChild(document.getElementById(modalElementId)); + } + + var modalElement = document.createElement('div'); + modalElement.style.cssText = 'position:fixed; z-index:99999; background:#fff; padding:20px; border-radius:5px; font-family:sans-serif; bottom:20px; right:20px; color:#000; max-width:400px; box-shadow:0 3px 10px rgb(0 0 0 / 0.2); font-family:arial; font-weight:normal; font-size:16px;'; + modalElement.innerHTML = message + "

"; + modalElement.id = modalElementId; + + var closeButton = document.createElement('button'); + closeButton.style.cssText = 'background-color:#008CBA; border:none; color:white; cursor:pointer; float: right; margin: 10px; padding:15px; border-radius:5px; display:inline-block; text-align:center; text-decoration:none; font-family:arial; font-weight:normal; font-size:16px;'; + closeButton.innerText = "Close"; + closeButton.addEventListener ("click", function() { + document.body.removeChild(document.getElementById(modalElementId)); + }); + modalElement.appendChild(closeButton); + + var qortalButton = document.createElement('button'); + qortalButton.style.cssText = 'background-color:#4CAF50; border:none; color:white; cursor:pointer; float: right; margin: 10px; padding:15px; border-radius:5px; text-align:center; text-decoration:none; display:inline-block; font-family:arial; font-weight:normal; font-size:16px;'; + qortalButton.innerText = "Learn more"; + qortalButton.addEventListener ("click", function() { + document.body.removeChild(document.getElementById(modalElementId)); + window.open("https://qortal.org"); + }); + modalElement.appendChild(qortalButton); + + document.body.appendChild(modalElement); +} + +window.addEventListener("message", (event) => { + if (event == null || event.data == null || event.data.length == 0) { + return; + } + if (event.data.action == null || event.data.requestedHandler == null) { + return; + } + if (event.data.requestedHandler != "UI") { + // Gateway mode only cares about requests that were intended for the UI + return; + } + + let response; + let data = event.data; + + switch (data.action) { + case "GET_USER_ACCOUNT": + case "PUBLISH_QDN_RESOURCE": + case "PUBLISH_MULTIPLE_QDN_RESOURCES": + case "SEND_CHAT_MESSAGE": + case "JOIN_GROUP": + case "DEPLOY_AT": + case "GET_WALLET_BALANCE": + case "SEND_COIN": + case "GET_LIST_ITEMS": + case "ADD_LIST_ITEMS": + case "DELETE_LIST_ITEM": + const errorString = "Interactive features were requested, but these are not yet supported when viewing via a gateway. To use interactive features, please access using the Qortal UI desktop app. More info at: https://qortal.org"; + response = "{\"error\": \"" + errorString + "\"}" + + const modalText = "This app is powered by the Qortal blockchain. You are viewing in read-only mode. To use interactive features, please access using the Qortal UI desktop app."; + qdnGatewayShowModal(modalText); + break; + + default: + console.log('Unhandled gateway message: ' + JSON.stringify(data)); + return; + } + + handleResponse(event, response); + +}, false); \ No newline at end of file diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js new file mode 100644 index 00000000..b638c621 --- /dev/null +++ b/src/main/resources/q-apps/q-apps.js @@ -0,0 +1,543 @@ +function httpGet(url) { + var request = new XMLHttpRequest(); + request.open("GET", url, false); + request.send(null); + return request.responseText; +} + +function httpGetAsyncWithEvent(event, url) { + fetch(url) + .then((response) => response.text()) + .then((responseText) => { + + if (responseText == null) { + // Pass to parent (UI), in case they can fulfil this request + event.data.requestedHandler = "UI"; + parent.postMessage(event.data, '*', [event.ports[0]]); + return; + } + + handleResponse(event, responseText); + + }) + .catch((error) => { + let res = {}; + res.error = error; + handleResponse(event, JSON.stringify(res)); + }) +} + +function handleResponse(event, response) { + if (event == null) { + return; + } + + // Handle empty or missing responses + if (response == null || response.length == 0) { + response = "{\"error\": \"Empty response\"}" + } + + // Parse response + let responseObj; + try { + responseObj = JSON.parse(response); + } catch (e) { + // Not all responses will be JSON + responseObj = response; + } + + // GET_QDN_RESOURCE_URL has custom handling + const data = event.data; + if (data.action == "GET_QDN_RESOURCE_URL") { + if (responseObj == null || responseObj.status == null || responseObj.status == "NOT_PUBLISHED") { + responseObj = {}; + responseObj.error = "Resource does not exist"; + } + else { + responseObj = buildResourceUrl(data.service, data.name, data.identifier, data.path, false); + } + } + + // Respond to app + if (responseObj.error != null) { + event.ports[0].postMessage({ + result: null, + error: responseObj + }); + } + else { + event.ports[0].postMessage({ + result: responseObj, + error: null + }); + } +} + +function buildResourceUrl(service, name, identifier, path, isLink) { + if (isLink == false) { + // If this URL isn't being used as a link, then we need to fetch the data + // synchronously, instead of showing the loading screen. + url = "/arbitrary/" + service + "/" + name; + if (identifier != null) url = url.concat("/" + identifier); + if (path != null) url = url.concat("?filepath=" + path); + } + else if (_qdnContext == "render") { + url = "/render/" + service + "/" + name; + if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); + if (identifier != null) url = url.concat("?identifier=" + identifier); + } + else if (_qdnContext == "gateway") { + url = "/" + service + "/" + name; + if (identifier != null) url = url.concat("/" + identifier); + if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); + } + else { + // domainMap only serves websites right now + url = "/" + name; + if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); + } + + if (isLink) url = url.concat((url.includes("?") ? "" : "?") + "&theme=" + _qdnTheme); + + return url; +} + +function extractComponents(url) { + if (!url.startsWith("qortal://")) { + return null; + } + + url = url.replace(/^(qortal\:\/\/)/,""); + if (url.includes("/")) { + let parts = url.split("/"); + const service = parts[0].toUpperCase(); + parts.shift(); + const name = parts[0]; + parts.shift(); + let identifier; + + if (parts.length > 0) { + identifier = parts[0]; // Do not shift yet + // Check if a resource exists with this service, name and identifier combination + const url = "/arbitrary/resource/status/" + service + "/" + name + "/" + identifier; + const response = httpGet(url); + const responseObj = JSON.parse(response); + if (responseObj.totalChunkCount > 0) { + // Identifier exists, so don't include it in the path + parts.shift(); + } + else { + identifier = null; + } + } + + const path = parts.join("/"); + + const components = {}; + components["service"] = service; + components["name"] = name; + components["identifier"] = identifier; + components["path"] = path; + return components; + } + + return null; +} + +function convertToResourceUrl(url, isLink) { + if (!url.startsWith("qortal://")) { + return null; + } + const c = extractComponents(url); + if (c == null) { + return null; + } + + return buildResourceUrl(c.service, c.name, c.identifier, c.path, isLink); +} + +window.addEventListener("message", (event) => { + if (event == null || event.data == null || event.data.length == 0) { + return; + } + if (event.data.action == null) { + // This could be a response from the UI + handleResponse(event, event.data); + } + if (event.data.requestedHandler != null && event.data.requestedHandler === "UI") { + // This request was destined for the UI, so ignore it + return; + } + + console.log("Core received action: " + JSON.stringify(event.data.action)); + + let url; + let data = event.data; + + switch (data.action) { + case "GET_ACCOUNT_DATA": + return httpGetAsyncWithEvent(event, "/addresses/" + data.address); + + case "GET_ACCOUNT_NAMES": + return httpGetAsyncWithEvent(event, "/names/address/" + data.address); + + case "SEARCH_NAMES": + url = "/names/search?"; + if (data.query != null) url = url.concat("&query=" + data.query); + if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + + case "GET_NAME_DATA": + return httpGetAsyncWithEvent(event, "/names/" + data.name); + + case "GET_QDN_RESOURCE_URL": + // Check status first; URL is built and returned automatically after status check + url = "/arbitrary/resource/status/" + data.service + "/" + data.name; + if (data.identifier != null) url = url.concat("/" + data.identifier); + return httpGetAsyncWithEvent(event, url); + + case "LINK_TO_QDN_RESOURCE": + if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE + window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path, true); + return; + + case "LIST_QDN_RESOURCES": + url = "/arbitrary/resources?"; + if (data.service != null) url = url.concat("&service=" + data.service); + if (data.name != null) url = url.concat("&name=" + data.name); + if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); + if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); + if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); + if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); + if (data.followedOnly != null) url = url.concat("&followedonly=" + new Boolean(data.followedOnly).toString()); + if (data.excludeBlocked != null) url = url.concat("&excludeblocked=" + new Boolean(data.excludeBlocked).toString()); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + + case "SEARCH_QDN_RESOURCES": + url = "/arbitrary/resources/search?"; + if (data.service != null) url = url.concat("&service=" + data.service); + if (data.query != null) url = url.concat("&query=" + data.query); + if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); + if (data.name != null) url = url.concat("&name=" + data.name); + if (data.names != null) data.names.forEach((x, i) => url = url.concat("&name=" + x)); + if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); + if (data.exactMatchNames != null) url = url.concat("&exactmatchnames=" + new Boolean(data.exactMatchNames).toString()); + if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); + if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); + if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); + if (data.followedOnly != null) url = url.concat("&followedonly=" + new Boolean(data.followedOnly).toString()); + if (data.excludeBlocked != null) url = url.concat("&excludeblocked=" + new Boolean(data.excludeBlocked).toString()); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + + case "FETCH_QDN_RESOURCE": + url = "/arbitrary/" + data.service + "/" + data.name; + if (data.identifier != null) url = url.concat("/" + data.identifier); + url = url.concat("?"); + if (data.filepath != null) url = url.concat("&filepath=" + data.filepath); + if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString()); + if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); + return httpGetAsyncWithEvent(event, url); + + case "GET_QDN_RESOURCE_STATUS": + url = "/arbitrary/resource/status/" + data.service + "/" + data.name; + if (data.identifier != null) url = url.concat("/" + data.identifier); + url = url.concat("?"); + if (data.build != null) url = url.concat("&build=" + new Boolean(data.build).toString()); + return httpGetAsyncWithEvent(event, url); + + case "GET_QDN_RESOURCE_PROPERTIES": + let identifier = (data.identifier != null) ? data.identifier : "default"; + url = "/arbitrary/resource/properties/" + data.service + "/" + data.name + "/" + identifier; + return httpGetAsyncWithEvent(event, url); + + case "GET_QDN_RESOURCE_METADATA": + identifier = (data.identifier != null) ? data.identifier : "default"; + url = "/arbitrary/metadata/" + data.service + "/" + data.name + "/" + identifier; + return httpGetAsyncWithEvent(event, url); + + case "SEARCH_CHAT_MESSAGES": + url = "/chat/messages?"; + if (data.before != null) url = url.concat("&before=" + data.before); + if (data.after != null) url = url.concat("&after=" + data.after); + if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId); + if (data.involving != null) data.involving.forEach((x, i) => url = url.concat("&involving=" + x)); + if (data.reference != null) url = url.concat("&reference=" + data.reference); + if (data.chatReference != null) url = url.concat("&chatreference=" + data.chatReference); + if (data.hasChatReference != null) url = url.concat("&haschatreference=" + new Boolean(data.hasChatReference).toString()); + if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + + case "LIST_GROUPS": + url = "/groups?"; + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + + case "GET_BALANCE": + url = "/addresses/balance/" + data.address; + if (data.assetId != null) url = url.concat("&assetId=" + data.assetId); + return httpGetAsyncWithEvent(event, url); + + case "GET_AT": + url = "/at" + data.atAddress; + return httpGetAsyncWithEvent(event, url); + + case "GET_AT_DATA": + url = "/at/" + data.atAddress + "/data"; + return httpGetAsyncWithEvent(event, url); + + case "LIST_ATS": + url = "/at/byfunction/" + data.codeHash58 + "?"; + if (data.isExecutable != null) url = url.concat("&isExecutable=" + data.isExecutable); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + + case "FETCH_BLOCK": + if (data.signature != null) { + url = "/blocks/" + data.signature; + } else if (data.height != null) { + url = "/blocks/byheight/" + data.height; + } + url = url.concat("?"); + if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); + return httpGetAsyncWithEvent(event, url); + + case "FETCH_BLOCK_RANGE": + url = "/blocks/range/" + data.height + "?"; + if (data.count != null) url = url.concat("&count=" + data.count); + if (data.reverse != null) url = url.concat("&reverse=" + data.reverse); + if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); + return httpGetAsyncWithEvent(event, url); + + case "SEARCH_TRANSACTIONS": + url = "/transactions/search?"; + if (data.startBlock != null) url = url.concat("&startBlock=" + data.startBlock); + if (data.blockLimit != null) url = url.concat("&blockLimit=" + data.blockLimit); + if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId); + if (data.txType != null) data.txType.forEach((x, i) => url = url.concat("&txType=" + x)); + if (data.address != null) url = url.concat("&address=" + data.address); + if (data.confirmationStatus != null) url = url.concat("&confirmationStatus=" + data.confirmationStatus); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + + case "GET_PRICE": + url = "/crosschain/price/" + data.blockchain + "?"; + if (data.maxtrades != null) url = url.concat("&maxtrades=" + data.maxtrades); + if (data.inverse != null) url = url.concat("&inverse=" + data.inverse); + return httpGetAsyncWithEvent(event, url); + + default: + // Pass to parent (UI), in case they can fulfil this request + event.data.requestedHandler = "UI"; + parent.postMessage(event.data, '*', [event.ports[0]]); + return; + } + +}, false); + + +/** + * Listen for and intercept all link click events + */ +function interceptClickEvent(e) { + var target = e.target || e.srcElement; + if (target.tagName !== 'A') { + target = target.closest('A'); + } + if (target == null || target.getAttribute('href') == null) { + return; + } + let href = target.getAttribute('href'); + if (href.startsWith("qortal://")) { + const c = extractComponents(href); + if (c != null) { + qortalRequest({ + action: "LINK_TO_QDN_RESOURCE", + service: c.service, + name: c.name, + identifier: c.identifier, + path: c.path + }); + } + e.preventDefault(); + } + else if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { + // Block external links + e.preventDefault(); + } +} +if (document.addEventListener) { + document.addEventListener('click', interceptClickEvent); +} +else if (document.attachEvent) { + document.attachEvent('onclick', interceptClickEvent); +} + + + +/** + * Intercept image loads from the DOM + */ +document.addEventListener('DOMContentLoaded', () => { + const imgElements = document.querySelectorAll('img'); + imgElements.forEach((img) => { + let url = img.src; + const newUrl = convertToResourceUrl(url, false); + if (newUrl != null) { + document.querySelector('img').src = newUrl; + } + }); +}); + +/** + * Intercept img src updates + */ +document.addEventListener('DOMContentLoaded', () => { + const imgElements = document.querySelectorAll('img'); + imgElements.forEach((img) => { + let observer = new MutationObserver((changes) => { + changes.forEach(change => { + if (change.attributeName.includes('src')) { + const newUrl = convertToResourceUrl(img.src, false); + if (newUrl != null) { + document.querySelector('img').src = newUrl; + } + } + }); + }); + observer.observe(img, {attributes: true}); + }); +}); + + + +const awaitTimeout = (timeout, reason) => + new Promise((resolve, reject) => + setTimeout( + () => (reason === undefined ? resolve() : reject(reason)), + timeout + ) + ); + +function getDefaultTimeout(action) { + if (action != null) { + // Some actions need longer default timeouts, especially those that create transactions + switch (action) { + case "GET_USER_ACCOUNT": + case "SAVE_FILE": + case "DECRYPT_DATA": + // User may take a long time to accept/deny the popup + return 60 * 60 * 1000; + + case "SEARCH_QDN_RESOURCES": + // Searching for data can be slow, especially when metadata and statuses are also being included + return 30 * 1000; + + case "FETCH_QDN_RESOURCE": + // Fetching data can take a while, especially if the status hasn't been checked first + return 60 * 1000; + + case "PUBLISH_QDN_RESOURCE": + case "PUBLISH_MULTIPLE_QDN_RESOURCES": + // Publishing could take a very long time on slow system, due to the proof-of-work computation + return 60 * 60 * 1000; + + case "SEND_CHAT_MESSAGE": + // Chat messages rely on PoW computations, so allow extra time + return 60 * 1000; + + case "JOIN_GROUP": + case "DEPLOY_AT": + case "SEND_COIN": + // Allow extra time for other actions that create transactions, even if there is no PoW + return 5 * 60 * 1000; + + case "GET_WALLET_BALANCE": + // Getting a wallet balance can take a while, if there are many transactions + return 2 * 60 * 1000; + + default: + break; + } + } + return 10 * 1000; +} + +/** + * Make a Qortal (Q-Apps) request with no timeout + */ +const qortalRequestWithNoTimeout = (request) => new Promise((res, rej) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = ({data}) => { + channel.port1.close(); + + if (data.error) { + rej(data.error); + } else { + res(data.result); + } + }; + + window.postMessage(request, '*', [channel.port2]); +}); + +/** + * Make a Qortal (Q-Apps) request with the default timeout (10 seconds) + */ +const qortalRequest = (request) => + Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(getDefaultTimeout(request.action), "The request timed out")]); + +/** + * Make a Qortal (Q-Apps) request with a custom timeout, specified in milliseconds + */ +const qortalRequestWithTimeout = (request, timeout) => + Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(timeout, "The request timed out")]); + + +/** + * Send current page details to UI + */ +document.addEventListener('DOMContentLoaded', () => { + qortalRequest({ + action: "QDN_RESOURCE_DISPLAYED", + service: _qdnService, + name: _qdnName, + identifier: _qdnIdentifier, + path: _qdnPath + }); +}); + +/** + * Handle app navigation + */ +navigation.addEventListener('navigate', (event) => { + const url = new URL(event.destination.url); + let fullpath = url.pathname + url.hash; + qortalRequest({ + action: "QDN_RESOURCE_DISPLAYED", + service: _qdnService, + name: _qdnName, + identifier: _qdnIdentifier, + path: (fullpath.startsWith(_qdnBase)) ? fullpath.slice(_qdnBase.length) : fullpath + }); +}); diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveV1Tests.java similarity index 68% rename from src/test/java/org/qortal/test/BlockArchiveTests.java rename to src/test/java/org/qortal/test/BlockArchiveV1Tests.java index 3bfa4e84..a28bd28d 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveV1Tests.java @@ -1,6 +1,7 @@ package org.qortal.test; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -10,8 +11,6 @@ import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.*; -import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; -import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; import org.qortal.test.common.AtUtils; @@ -23,11 +22,9 @@ import org.qortal.transform.TransformationException; import org.qortal.transform.block.BlockTransformation; import org.qortal.utils.BlockArchiveUtils; import org.qortal.utils.NTP; -import org.qortal.utils.Triple; import java.io.File; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.SQLException; @@ -35,13 +32,16 @@ import java.util.List; import static org.junit.Assert.*; -public class BlockArchiveTests extends Common { +public class BlockArchiveV1Tests extends Common { @Before - public void beforeTest() throws DataException { + public void beforeTest() throws DataException, IllegalAccessException { Common.useSettings("test-settings-v2-block-archive.json"); NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); this.deleteArchiveDirectory(); + + // Set default archive version to 1, so that archive builds in these tests use V2 + FieldUtils.writeField(Settings.getInstance(), "defaultArchiveVersion", 1, true); } @After @@ -314,9 +314,10 @@ public class BlockArchiveTests extends Common { repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); - assertEquals(900-1, numATStatesPruned); + assertEquals(900-2, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state repository.getATRepository().setAtPruneHeight(901); // Now ensure the SQL repository is missing blocks 2 and 900... @@ -333,212 +334,6 @@ public class BlockArchiveTests extends Common { } } - @Test - public void testBulkArchiveAndPrune() throws DataException, SQLException { - try (final Repository repository = RepositoryManager.getRepository()) { - HSQLDBRepository hsqldb = (HSQLDBRepository) repository; - - // Deploy an AT so that we have AT state data - PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); - byte[] creationBytes = AtUtils.buildSimpleAT(); - long fundingAmount = 1_00000000L; - AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); - - // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) { - BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); - } - - // Assume 900 blocks are trimmed (this specifies the first untrimmed height) - repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); - repository.getATRepository().setAtTrimHeight(901); - - // Check the max archive height - this should be one less than the first untrimmed height - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - assertEquals(900, maximumArchiveHeight); - - // Check the current archive height - assertEquals(0, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Write blocks 2-900 to the archive (using bulk method) - int fileSizeTarget = 428600; // Pre-calculated size of 900 blocks - assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, fileSizeTarget)); - - // Ensure the block archive height has increased - assertEquals(901, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Ensure the SQL repository contains blocks 2 and 900... - assertNotNull(repository.getBlockRepository().fromHeight(2)); - assertNotNull(repository.getBlockRepository().fromHeight(900)); - - // Check the current prune heights - assertEquals(0, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(0, repository.getATRepository().getAtPruneHeight()); - - // Prior to archiving or pruning, ensure blocks 2 to 1002 and their AT states are available in the db - for (int i=2; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Prune all the archived blocks and AT states (using bulk method) - assertTrue(HSQLDBDatabasePruning.pruneBlocks(hsqldb)); - assertTrue(HSQLDBDatabasePruning.pruneATStates(hsqldb)); - - // Ensure the current prune heights have increased - assertEquals(901, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(901, repository.getATRepository().getAtPruneHeight()); - - // Now ensure the SQL repository is missing blocks 2 and 900... - assertNull(repository.getBlockRepository().fromHeight(2)); - assertNull(repository.getBlockRepository().fromHeight(900)); - - // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) - assertNotNull(repository.getBlockRepository().fromHeight(1)); - assertNotNull(repository.getBlockRepository().fromHeight(901)); - - // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); - - // Ensure blocks 2-900 are all available in the archive - for (int i=2; i<=900; i++) { - assertNotNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - - // Ensure blocks 2-900 are NOT available in the db - for (int i=2; i<=900; i++) { - assertNull(repository.getBlockRepository().fromHeight(i)); - } - - // Ensure blocks 901 to 1002 and their AT states are available in the db - for (int i=901; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Ensure blocks 901 to 1002 are not available in the archive - for (int i=901; i<=1002; i++) { - assertNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - } - } - - @Test - public void testBulkArchiveAndPruneMultipleFiles() throws DataException, SQLException { - try (final Repository repository = RepositoryManager.getRepository()) { - HSQLDBRepository hsqldb = (HSQLDBRepository) repository; - - // Deploy an AT so that we have AT state data - PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); - byte[] creationBytes = AtUtils.buildSimpleAT(); - long fundingAmount = 1_00000000L; - AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); - - // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) { - BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); - } - - // Assume 900 blocks are trimmed (this specifies the first untrimmed height) - repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); - repository.getATRepository().setAtTrimHeight(901); - - // Check the max archive height - this should be one less than the first untrimmed height - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - assertEquals(900, maximumArchiveHeight); - - // Check the current archive height - assertEquals(0, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Write blocks 2-900 to the archive (using bulk method) - int fileSizeTarget = 42360; // Pre-calculated size of approx 90 blocks - assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, fileSizeTarget)); - - // Ensure 10 archive files have been created - Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive"); - assertEquals(10, new File(archivePath.toString()).list().length); - - // Check the files exist - assertTrue(Files.exists(Paths.get(archivePath.toString(), "2-90.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "91-179.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "180-268.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "269-357.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "358-446.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "447-535.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "536-624.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "625-713.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "714-802.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "803-891.dat"))); - - // Ensure the block archive height has increased - // It won't be as high as 901, because blocks 892-901 were too small to reach the file size - // target of the 11th file - assertEquals(892, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Ensure the SQL repository contains blocks 2 and 891... - assertNotNull(repository.getBlockRepository().fromHeight(2)); - assertNotNull(repository.getBlockRepository().fromHeight(891)); - - // Check the current prune heights - assertEquals(0, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(0, repository.getATRepository().getAtPruneHeight()); - - // Prior to archiving or pruning, ensure blocks 2 to 1002 and their AT states are available in the db - for (int i=2; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Prune all the archived blocks and AT states (using bulk method) - assertTrue(HSQLDBDatabasePruning.pruneBlocks(hsqldb)); - assertTrue(HSQLDBDatabasePruning.pruneATStates(hsqldb)); - - // Ensure the current prune heights have increased - assertEquals(892, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(892, repository.getATRepository().getAtPruneHeight()); - - // Now ensure the SQL repository is missing blocks 2 and 891... - assertNull(repository.getBlockRepository().fromHeight(2)); - assertNull(repository.getBlockRepository().fromHeight(891)); - - // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) - assertNotNull(repository.getBlockRepository().fromHeight(1)); - assertNotNull(repository.getBlockRepository().fromHeight(892)); - - // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); - - // Ensure blocks 2-891 are all available in the archive - for (int i=2; i<=891; i++) { - assertNotNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - - // Ensure blocks 2-891 are NOT available in the db - for (int i=2; i<=891; i++) { - assertNull(repository.getBlockRepository().fromHeight(i)); - } - - // Ensure blocks 892 to 1002 and their AT states are available in the db - for (int i=892; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Ensure blocks 892 to 1002 are not available in the archive - for (int i=892; i<=1002; i++) { - assertNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - } - } - @Test public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -563,16 +358,23 @@ public class BlockArchiveTests extends Common { // Trim the first 500 blocks repository.getBlockRepository().trimOldOnlineAccountsSignatures(0, 500); repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(501); + repository.getATRepository().rebuildLatestAtStates(500); repository.getATRepository().trimAtStates(0, 500, 1000); repository.getATRepository().setAtTrimHeight(501); - // Now block 500 should only have the AT state data hash - block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); - atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + // Now block 499 should only have the AT state data hash + List block499AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(499); + atStatesData = repository.getATRepository().getATStateAtHeight(block499AtStatesData.get(0).getATAddress(), 499); assertNotNull(atStatesData.getStateHash()); assertNull(atStatesData.getStateData()); - // ... but block 501 should have the full data + // ... but block 500 should have the full data (due to being retained as the "latest" AT state in the trimmed range + block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // ... and block 501 should also have the full data List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); assertNotNull(atStatesData.getStateHash()); @@ -612,9 +414,10 @@ public class BlockArchiveTests extends Common { repository.getBlockRepository().setBlockPruneHeight(501); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(500); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); - assertEquals(499, numATStatesPruned); + assertEquals(498, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state repository.getATRepository().setAtPruneHeight(501); // Now ensure the SQL repository is missing blocks 2 and 500... diff --git a/src/test/java/org/qortal/test/BlockArchiveV2Tests.java b/src/test/java/org/qortal/test/BlockArchiveV2Tests.java new file mode 100644 index 00000000..3b1d12d3 --- /dev/null +++ b/src/test/java/org/qortal/test/BlockArchiveV2Tests.java @@ -0,0 +1,504 @@ +package org.qortal.test; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.BlockMinter; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.*; +import org.qortal.repository.hsqldb.HSQLDBRepository; +import org.qortal.settings.Settings; +import org.qortal.test.common.AtUtils; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformation; +import org.qortal.utils.BlockArchiveUtils; +import org.qortal.utils.NTP; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.SQLException; +import java.util.List; + +import static org.junit.Assert.*; + +public class BlockArchiveV2Tests extends Common { + + @Before + public void beforeTest() throws DataException, IllegalAccessException { + Common.useSettings("test-settings-v2-block-archive.json"); + NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); + this.deleteArchiveDirectory(); + + // Set default archive version to 2, so that archive builds in these tests use V2 + FieldUtils.writeField(Settings.getInstance(), "defaultArchiveVersion", 2, true); + } + + @After + public void afterTest() throws DataException { + this.deleteArchiveDirectory(); + } + + + @Test + public void testWriter() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + } + } + + @Test + public void testWriterAndReader() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Read block 2 from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + BlockTransformation block2Info = reader.fetchBlockAtHeight(2); + BlockData block2ArchiveData = block2Info.getBlockData(); + + // Read block 2 from the repository + BlockData block2RepositoryData = repository.getBlockRepository().fromHeight(2); + + // Ensure the values match + assertEquals(block2ArchiveData.getHeight(), block2RepositoryData.getHeight()); + assertArrayEquals(block2ArchiveData.getSignature(), block2RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block2ArchiveData.getOnlineAccountsCount()); + + // Read block 900 from the archive + BlockTransformation block900Info = reader.fetchBlockAtHeight(900); + BlockData block900ArchiveData = block900Info.getBlockData(); + + // Read block 900 from the repository + BlockData block900RepositoryData = repository.getBlockRepository().fromHeight(900); + + // Ensure the values match + assertEquals(block900ArchiveData.getHeight(), block900RepositoryData.getHeight()); + assertArrayEquals(block900ArchiveData.getSignature(), block900RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block900ArchiveData.getOnlineAccountsCount()); + + } + } + + @Test + public void testArchivedAtStates() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // 9 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(10); + repository.getATRepository().setAtTrimHeight(10); + + // Check the max archive height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(9, maximumArchiveHeight); + + // Write blocks 2-9 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(9 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(9 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Check blocks 3-9 + for (Integer testHeight = 2; testHeight <= 9; testHeight++) { + + // Read a block from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + BlockTransformation blockInfo = reader.fetchBlockAtHeight(testHeight); + BlockData archivedBlockData = blockInfo.getBlockData(); + byte[] archivedAtStateHash = blockInfo.getAtStatesHash(); + List archivedTransactions = blockInfo.getTransactions(); + + // Read the same block from the repository + BlockData repositoryBlockData = repository.getBlockRepository().fromHeight(testHeight); + ATStateData repositoryAtStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + // Ensure the repository has full AT state data + assertNotNull(repositoryAtStateData.getStateHash()); + assertNotNull(repositoryAtStateData.getStateData()); + + // Check the archived AT state + if (testHeight == 2) { + assertEquals(1, archivedTransactions.size()); + assertEquals(Transaction.TransactionType.DEPLOY_AT, archivedTransactions.get(0).getType()); + } + else { + // Blocks 3+ shouldn't have any transactions + assertTrue(archivedTransactions.isEmpty()); + } + + // Ensure the archive has the AT states hash + assertNotNull(archivedAtStateHash); + + // Also check the online accounts count and height + assertEquals(1, archivedBlockData.getOnlineAccountsCount()); + assertEquals(testHeight, archivedBlockData.getHeight()); + + // Ensure the values match + assertEquals(archivedBlockData.getHeight(), repositoryBlockData.getHeight()); + assertArrayEquals(archivedBlockData.getSignature(), repositoryBlockData.getSignature()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getMinterSignature(), repositoryBlockData.getMinterSignature()); + assertEquals(archivedBlockData.getATCount(), repositoryBlockData.getATCount()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getReference(), repositoryBlockData.getReference()); + assertEquals(archivedBlockData.getTimestamp(), repositoryBlockData.getTimestamp()); + assertEquals(archivedBlockData.getATFees(), repositoryBlockData.getATFees()); + assertEquals(archivedBlockData.getTotalFees(), repositoryBlockData.getTotalFees()); + assertEquals(archivedBlockData.getTransactionCount(), repositoryBlockData.getTransactionCount()); + assertArrayEquals(archivedBlockData.getTransactionsSignature(), repositoryBlockData.getTransactionsSignature()); + + // TODO: build atStatesHash and compare against value in archive + } + + // Check block 10 (unarchived) + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + BlockTransformation blockInfo = reader.fetchBlockAtHeight(10); + assertNull(blockInfo); + + } + + } + + @Test + public void testArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // Assume 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(901); + repository.saveChanges(); + assertEquals(901, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 900... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(900)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 900); + assertEquals(900-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(901); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); + assertEquals(900-2, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state + repository.getATRepository().setAtPruneHeight(901); + + // Now ensure the SQL repository is missing blocks 2 and 900... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(900)); + + // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(901)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + } + } + + @Test + public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // Make sure that block 500 has full AT state data and data hash + List block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Trim the first 500 blocks + repository.getBlockRepository().trimOldOnlineAccountsSignatures(0, 500); + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(501); + repository.getATRepository().rebuildLatestAtStates(500); + repository.getATRepository().trimAtStates(0, 500, 1000); + repository.getATRepository().setAtTrimHeight(501); + + // Now block 499 should only have the AT state data hash + List block499AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(499); + atStatesData = repository.getATRepository().getATStateAtHeight(block499AtStatesData.get(0).getATAddress(), 499); + assertNotNull(atStatesData.getStateHash()); + assertNull(atStatesData.getStateData()); + + // ... but block 500 should have the full data (due to being retained as the "latest" AT state in the trimmed range + block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // ... and block 501 should also have the full data + List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); + atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(500, maximumArchiveHeight); + + BlockData block3DataPreArchive = repository.getBlockRepository().fromHeight(3); + + // Write blocks 2-500 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(500 - 1, writer.getWrittenCount()); // -1 for the genesis block + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(500 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 500... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(500)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 500); + assertEquals(500-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(501); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(500); + repository.saveChanges(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); + assertEquals(498, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state + repository.getATRepository().setAtPruneHeight(501); + + // Now ensure the SQL repository is missing blocks 2 and 500... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(500)); + + // ... but it's not missing blocks 1 and 501 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(501)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // Now orphan some unarchived blocks. + BlockUtils.orphanBlocks(repository, 500); + assertEquals(502, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // We're close to the lower limit of the SQL database now, so + // we need to import some blocks from the archive + BlockArchiveUtils.importFromArchive(401, 500, repository); + + // Ensure the SQL repository now contains block 401 but not 400... + assertNotNull(repository.getBlockRepository().fromHeight(401)); + assertNull(repository.getBlockRepository().fromHeight(400)); + + // Import the remaining 399 blocks + BlockArchiveUtils.importFromArchive(2, 400, repository); + + // Verify that block 3 matches the original + BlockData block3DataPostArchive = repository.getBlockRepository().fromHeight(3); + assertArrayEquals(block3DataPreArchive.getSignature(), block3DataPostArchive.getSignature()); + assertEquals(block3DataPreArchive.getHeight(), block3DataPostArchive.getHeight()); + + // Orphan 2 more block, which should be the last one that is possible to be orphaned + // TODO: figure out why this is 1 block more than in the equivalent block archive V1 test + BlockUtils.orphanBlocks(repository, 2); + + // Orphan another block, which should fail + Exception exception = null; + try { + BlockUtils.orphanBlocks(repository, 1); + } catch (DataException e) { + exception = e; + } + + // Ensure that a DataException is thrown because there is no more AT states data available + assertNotNull(exception); + assertEquals(DataException.class, exception.getClass()); + + // FUTURE: we may be able to retain unique AT states when trimming, to avoid this exception + // and allow orphaning back through blocks with trimmed AT states. + + } + } + + + /** + * Many nodes are missing an ATStatesHeightIndex due to an earlier bug + * In these cases we disable archiving and pruning as this index is a + * very essential component in these processes. + */ + @Test + public void testMissingAtStatesHeightIndex() throws DataException, SQLException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + // Firstly check that we're able to prune or archive when the index exists + assertTrue(repository.getATRepository().hasAtStatesHeightIndex()); + assertTrue(RepositoryManager.canArchiveOrPrune()); + + // Delete the index + repository.prepareStatement("DROP INDEX ATSTATESHEIGHTINDEX").execute(); + + // Ensure check that we're unable to prune or archive when the index doesn't exist + assertFalse(repository.getATRepository().hasAtStatesHeightIndex()); + assertFalse(RepositoryManager.canArchiveOrPrune()); + } + } + + + private void deleteArchiveDirectory() { + // Delete archive directory if exists + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + try { + FileUtils.deleteDirectory(archivePath.toFile()); + } catch (IOException e) { + + } + } + +} diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index aa641e71..58e1cfa2 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -176,7 +176,8 @@ public class BootstrapTests extends Common { repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); repository.getATRepository().pruneAtStates(0, 900); repository.getATRepository().setAtPruneHeight(901); @@ -211,7 +212,7 @@ public class BootstrapTests extends Common { @Test public void testBootstrapHosts() throws IOException { String[] bootstrapHosts = Settings.getInstance().getBootstrapHosts(); - String[] bootstrapTypes = { "archive", "toponly" }; + String[] bootstrapTypes = { "archive" }; // , "toponly" for (String host : bootstrapHosts) { for (String type : bootstrapTypes) { diff --git a/src/test/java/org/qortal/test/ListTests.java b/src/test/java/org/qortal/test/ListTests.java new file mode 100644 index 00000000..75ff8d36 --- /dev/null +++ b/src/test/java/org/qortal/test/ListTests.java @@ -0,0 +1,129 @@ +package org.qortal.test; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.list.ResourceList; +import org.qortal.list.ResourceListManager; +import org.qortal.repository.DataException; +import org.qortal.settings.Settings; +import org.qortal.test.common.Common; +import org.qortal.utils.ListUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.Assert.*; + +public class ListTests { + + @Before + public void beforeTest() throws DataException, IOException { + Common.useDefaultSettings(); + this.cleanup(); + } + + @After + public void afterTest() throws DataException, IOException { + this.cleanup(); + } + + private void cleanup() throws IOException { + // Delete custom lists created by test methods + ResourceList followedNamesTestList = new ResourceList("followedNames_test"); + followedNamesTestList.clear(); + followedNamesTestList.save(); + + ResourceList blockedNamesTestList = new ResourceList("blockedNames_test"); + blockedNamesTestList.clear(); + blockedNamesTestList.save(); + + // Clear resource list manager instance + ResourceListManager.reset(); + } + + @Test + public void testSingleList() { + ResourceListManager resourceListManager = ResourceListManager.getInstance(); + String listName = "followedNames_test"; + String name = "testName"; + + resourceListManager.addToList(listName, name, false); + + List followedNames = resourceListManager.getStringsInList(listName); + assertEquals(1, followedNames.size()); + assertEquals(followedNames.size(), ListUtils.followedNamesCount()); + assertEquals(name, followedNames.get(0)); + } + + @Test + public void testListPrefix() { + ResourceListManager resourceListManager = ResourceListManager.getInstance(); + + List initialFollowedNames = resourceListManager.getStringsInListsWithPrefix("followedNames"); + assertEquals(0, initialFollowedNames.size()); + + List initialBlockedNames = resourceListManager.getStringsInListsWithPrefix("blockedNames"); + assertEquals(0, initialBlockedNames.size()); + + // Add to multiple lists + resourceListManager.addToList("followedNames_CustomList1", "testName1", false); + resourceListManager.addToList("followedNames_CustomList1", "testName2", false); + resourceListManager.addToList("followedNames_CustomList2", "testName3", false); + resourceListManager.addToList("followedNames_CustomList3", "testName4", false); + resourceListManager.addToList("blockedNames_CustomList1", "testName5", false); + + // Check followedNames + List followedNames = resourceListManager.getStringsInListsWithPrefix("followedNames"); + assertEquals(4, followedNames.size()); + assertEquals(followedNames.size(), ListUtils.followedNamesCount()); + assertTrue(followedNames.contains("testName1")); + assertTrue(followedNames.contains("testName2")); + assertTrue(followedNames.contains("testName3")); + assertTrue(followedNames.contains("testName4")); + assertFalse(followedNames.contains("testName5")); + + // Check blockedNames + List blockedNames = resourceListManager.getStringsInListsWithPrefix("blockedNames"); + assertEquals(1, blockedNames.size()); + assertEquals(blockedNames.size(), ListUtils.blockedNames().size()); + assertTrue(blockedNames.contains("testName5")); + } + + @Test + public void testDataPersistence() { + // Ensure lists are empty to begin with + assertEquals(0, ResourceListManager.getInstance().getStringsInListsWithPrefix("followedNames").size()); + assertEquals(0, ResourceListManager.getInstance().getStringsInListsWithPrefix("blockedNames").size()); + + // Add some items to multiple lists + ResourceListManager.getInstance().addToList("followedNames_test", "testName1", true); + ResourceListManager.getInstance().addToList("followedNames_test", "testName2", true); + ResourceListManager.getInstance().addToList("blockedNames_test", "testName3", true); + + // Ensure they are added + assertEquals(2, ResourceListManager.getInstance().getStringsInListsWithPrefix("followedNames").size()); + assertEquals(1, ResourceListManager.getInstance().getStringsInListsWithPrefix("blockedNames").size()); + + // Clear local state + ResourceListManager.reset(); + + // Ensure items are automatically loaded back in from disk + assertEquals(2, ResourceListManager.getInstance().getStringsInListsWithPrefix("followedNames").size()); + assertEquals(1, ResourceListManager.getInstance().getStringsInListsWithPrefix("blockedNames").size()); + + // Delete followedNames file + File followedNamesFile = Paths.get(Settings.getInstance().getListsPath(), "followedNames_test.json").toFile(); + followedNamesFile.delete(); + + // Clear local state again + ResourceListManager.reset(); + + // Ensure only the blocked names are loaded back in + assertEquals(0, ResourceListManager.getInstance().getStringsInListsWithPrefix("followedNames").size()); + assertEquals(1, ResourceListManager.getInstance().getStringsInListsWithPrefix("blockedNames").size()); + } + +} diff --git a/src/test/java/org/qortal/test/MemoryPoWTests.java b/src/test/java/org/qortal/test/MemoryPoWTests.java index 662fab19..3b0045e5 100644 --- a/src/test/java/org/qortal/test/MemoryPoWTests.java +++ b/src/test/java/org/qortal/test/MemoryPoWTests.java @@ -3,6 +3,8 @@ package org.qortal.test; import org.junit.Ignore; import org.junit.Test; import org.qortal.crypto.MemoryPoW; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; import static org.junit.Assert.*; @@ -39,13 +41,14 @@ public class MemoryPoWTests { } @Test - public void testMultipleComputes() { + public void testMultipleComputes() throws DataException { + Common.useDefaultSettings(); Random random = new Random(); - final int sampleSize = 20; + final int sampleSize = 10; final long stddevDivisor = sampleSize * (sampleSize - 1); - for (int difficulty = 8; difficulty < 16; difficulty += 2) { + for (int difficulty = 8; difficulty <= 16; difficulty++) { byte[] data = new byte[256]; long[] times = new long[sampleSize]; diff --git a/src/test/java/org/qortal/test/MessageTests.java b/src/test/java/org/qortal/test/MessageTests.java index f08c7b2f..c76c715e 100644 --- a/src/test/java/org/qortal/test/MessageTests.java +++ b/src/test/java/org/qortal/test/MessageTests.java @@ -1,5 +1,6 @@ package org.qortal.test; +import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -14,12 +15,9 @@ import org.qortal.group.Group.ApprovalThreshold; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.GroupUtils; -import org.qortal.test.common.TestAccount; -import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.*; import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; @@ -31,6 +29,7 @@ import org.qortal.utils.NTP; import static org.junit.Assert.*; +import java.util.List; import java.util.Random; public class MessageTests extends Common { @@ -85,7 +84,7 @@ public class MessageTests extends Common { byte[] randomReference = new byte[64]; random.nextBytes(randomReference); - long minimumFee = BlockChain.getInstance().getUnitFee(); + long minimumFee = BlockChain.getInstance().getUnitFeeAtTimestamp(System.currentTimeMillis()); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); @@ -139,7 +138,7 @@ public class MessageTests extends Common { } @Test - public void withRecipentWithAmount() throws DataException { + public void withRecipientWithAmount() throws DataException { testMessage(Group.NO_GROUP, recipient, 123L, Asset.QORT); } @@ -153,6 +152,140 @@ public class MessageTests extends Common { testMessage(1, null, 0L, null); } + @Test + public void atRecipientNoFeeWithNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String atRecipient = deployAt(); + MessageTransaction transaction = testFeeNonce(repository, false, true, atRecipient, true); + + // Transaction should be confirmable because it's to an AT, and therefore should be present in a block + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); + assertEquals(16, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void regularRecipientNoFeeWithNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + + // Transaction should not be present in db yet + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, recipient, null, null, null); + assertTrue(messageTransactionsData.isEmpty()); + + MessageTransaction transaction = testFeeNonce(repository, false, true, recipient, true); + + // Transaction shouldn't be confirmable because it's not to an AT, and therefore shouldn't be present in a block + assertFalse(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertFalse(isTransactionConfirmed(repository, transaction)); + assertEquals(12, transaction.getPoWDifficulty()); + + // Transaction should be found when trade bot searches for it + messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, recipient, null, null, null); + assertEquals(1, messageTransactionsData.size()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void noRecipientNoFeeWithNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + + MessageTransaction transaction = testFeeNonce(repository, false, true, null, true); + + // Transaction shouldn't be confirmable because it's not to an AT, and therefore shouldn't be present in a block + assertFalse(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertFalse(isTransactionConfirmed(repository, transaction)); + assertEquals(12, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void atRecipientWithFeeNoNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String atRecipient = deployAt(); + MessageTransaction transaction = testFeeNonce(repository, true, false, atRecipient, true); + + // Transaction should be confirmable because it's to an AT, and therefore should be present in a block + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); + assertEquals(16, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void regularRecipientWithFeeNoNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + + MessageTransaction transaction = testFeeNonce(repository, true, false, recipient, true); + + // Transaction shouldn't be confirmable because it's not to an AT, and therefore shouldn't be present in a block + assertFalse(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertFalse(isTransactionConfirmed(repository, transaction)); + assertEquals(12, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void atRecipientNoFeeWithNonceLegacyDifficulty() throws DataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Set mempowTransactionUpdatesTimestamp to a high value, so that it hasn't activated key + FieldUtils.writeField(BlockChain.getInstance(), "mempowTransactionUpdatesTimestamp", Long.MAX_VALUE, true); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String atRecipient = deployAt(); + MessageTransaction transaction = testFeeNonce(repository, false, true, atRecipient, true); + + // Transaction should be confirmable because all MESSAGE transactions confirmed prior to the feature trigger + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); + assertEquals(14, transaction.getPoWDifficulty()); // Legacy difficulty was 14 in all cases + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void regularRecipientNoFeeWithNonceLegacyDifficulty() throws DataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Set mempowTransactionUpdatesTimestamp to a high value, so that it hasn't activated key + FieldUtils.writeField(BlockChain.getInstance(), "mempowTransactionUpdatesTimestamp", Long.MAX_VALUE, true); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + MessageTransaction transaction = testFeeNonce(repository, false, true, recipient, true); + + // Transaction should be confirmable because all MESSAGE transactions confirmed prior to the feature trigger + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); // All MESSAGE transactions would confirm before feature trigger + assertEquals(14, transaction.getPoWDifficulty()); // Legacy difficulty was 14 in all cases + + BlockUtils.orphanLastBlock(repository); + } + } + @Test public void serializationTests() throws DataException, TransformationException { // with recipient, with amount @@ -165,6 +298,24 @@ public class MessageTests extends Common { testSerialization(null, 0L, null); } + private String deployAt() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + String address = deployAtTransaction.getATAccount().getAddress(); + assertNotNull(address); + return address; + } + } + + private boolean isTransactionConfirmed(Repository repository, MessageTransaction transaction) throws DataException { + TransactionData queriedTransactionData = repository.getTransactionRepository().fromSignature(transaction.getTransactionData().getSignature()); + return queriedTransactionData.getBlockHeight() != null && queriedTransactionData.getBlockHeight() > 0; + } + private boolean isValid(int txGroupId, String recipient, long amount, Long assetId) throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); @@ -195,41 +346,48 @@ public class MessageTests extends Common { return messageTransaction.hasValidReference(); } - private void testFeeNonce(boolean withFee, boolean withNonce, boolean isValid) throws DataException { + + private MessageTransaction testFeeNonce(boolean withFee, boolean withNonce, boolean isValid) throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { - TestAccount alice = Common.getTestAccount(repository, "alice"); - - int txGroupId = 0; - int nonce = 0; - long amount = 0; - long assetId = Asset.QORT; - byte[] data = new byte[1]; - boolean isText = false; - boolean isEncrypted = false; - - MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId), - version, nonce, recipient, amount, assetId, data, isText, isEncrypted); - - MessageTransaction transaction = new MessageTransaction(repository, transactionData); - - if (withFee) - transactionData.setFee(transaction.calcRecommendedFee()); - else - transactionData.setFee(0L); - - if (withNonce) { - transaction.computeNonce(); - } else { - transactionData.setNonce(-1); - } - - transaction.sign(alice); - - assertEquals(isValid, transaction.isSignatureValid()); + return testFeeNonce(repository, withFee, withNonce, recipient, isValid); } } - private void testMessage(int txGroupId, String recipient, long amount, Long assetId) throws DataException { + private MessageTransaction testFeeNonce(Repository repository, boolean withFee, boolean withNonce, String recipient, boolean isValid) throws DataException { + TestAccount alice = Common.getTestAccount(repository, "alice"); + + int txGroupId = 0; + int nonce = 0; + long amount = 0; + long assetId = Asset.QORT; + byte[] data = new byte[1]; + boolean isText = false; + boolean isEncrypted = false; + + MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId), + version, nonce, recipient, amount, assetId, data, isText, isEncrypted); + + MessageTransaction transaction = new MessageTransaction(repository, transactionData); + + if (withFee) + transactionData.setFee(transaction.calcRecommendedFee()); + else + transactionData.setFee(0L); + + if (withNonce) { + transaction.computeNonce(); + } else { + transactionData.setNonce(-1); + } + + transaction.sign(alice); + + assertEquals(isValid, transaction.isSignatureValid()); + + return transaction; + } + + private MessageTransaction testMessage(int txGroupId, String recipient, long amount, Long assetId) throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); @@ -244,6 +402,8 @@ public class MessageTests extends Common { TransactionUtils.signAndMint(repository, transactionData, alice); BlockUtils.orphanLastBlock(repository); + + return new MessageTransaction(repository, transactionData); } } diff --git a/src/test/java/org/qortal/test/PruneTests.java b/src/test/java/org/qortal/test/PruneTests.java index 0914d794..5a31146e 100644 --- a/src/test/java/org/qortal/test/PruneTests.java +++ b/src/test/java/org/qortal/test/PruneTests.java @@ -1,16 +1,33 @@ package org.qortal.test; +import com.google.common.hash.HashCode; import org.junit.Before; import org.junit.Test; +import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; import org.qortal.controller.BlockMinter; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.LitecoinACCTv3; +import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.test.common.AtUtils; +import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; import java.util.ArrayList; import java.util.List; @@ -19,6 +36,13 @@ import static org.junit.Assert.*; public class PruneTests extends Common { + // Constants for test AT (an LTC ACCT) + public static final byte[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long litecoinAmount = 864200L; // 0.00864200 LTC + @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); @@ -62,23 +86,32 @@ public class PruneTests extends Common { repository.getBlockRepository().setBlockPruneHeight(6); // Prune AT states for blocks 2-5 + repository.getATRepository().rebuildLatestAtStates(5); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 5); - assertEquals(4, numATStatesPruned); + assertEquals(3, numATStatesPruned); repository.getATRepository().setAtPruneHeight(6); - // Make sure that blocks 2-5 are now missing block data and AT states data - for (Integer i=2; i <= 5; i++) { + // Make sure that blocks 2-4 are now missing block data and AT states data + for (Integer i=2; i <= 4; i++) { BlockData blockData = repository.getBlockRepository().fromHeight(i); assertNull(blockData); List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); assertTrue(atStatesDataList.isEmpty()); } - // ... but blocks 6-10 have block data and full AT states data + // Block 5 should have full AT states data even though it was pruned. + // This is because we identified that as the "latest" AT state in that block range + BlockData blockData = repository.getBlockRepository().fromHeight(5); + assertNull(blockData); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(5); + assertEquals(1, atStatesDataList.size()); + + // Blocks 6-10 have block data and full AT states data for (Integer i=6; i <= 10; i++) { - BlockData blockData = repository.getBlockRepository().fromHeight(i); + blockData = repository.getBlockRepository().fromHeight(i); assertNotNull(blockData.getSignature()); - List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); assertNotNull(atStatesDataList); assertFalse(atStatesDataList.isEmpty()); ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(atStatesDataList.get(0).getATAddress(), i); @@ -88,4 +121,102 @@ public class PruneTests extends Common { } } + @Test + public void testPruneSleepingAt() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = Common.getTestAccount(repository, "alice"); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + // Mint enough blocks to take the original DEPLOY_AT past the prune threshold (in this case 20) + Block block = BlockUtils.mintBlocks(repository, 25); + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = LitecoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); + long txTimestamp = block.getBlockData().getTimestamp(); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress, txTimestamp); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + // Prune AT states up to block 20 + repository.getATRepository().rebuildLatestAtStates(20); + repository.saveChanges(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 20); + assertEquals(1, numATStatesPruned); // deleted state at heights 2, but state at height 3 remains + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Test orphaning - should be possible because the previous AT state at height 3 is still available + BlockUtils.orphanLastBlock(repository); + } + } + + + // Helper methods for AT testing + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = LitecoinACCTv3.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-LTC cross-chain trade"; + String description = String.format("Qortal-Litecoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-LTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient, long txTimestamp) throws DataException { + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } } diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index bb6510d5..30cbaea5 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -9,6 +9,7 @@ import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountBalanceData; import org.qortal.data.account.AccountData; +import org.qortal.data.chat.ChatMessage; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -417,7 +418,7 @@ public class RepositoryTests extends Common { try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { String address = Crypto.toAddress(new byte[32]); - hsqldb.getChatRepository().getActiveChats(address); + hsqldb.getChatRepository().getActiveChats(address, ChatMessage.Encoding.BASE58); } catch (DataException e) { fail("HSQLDB bug #1580"); } diff --git a/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java new file mode 100644 index 00000000..397a1bbe --- /dev/null +++ b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java @@ -0,0 +1,1572 @@ +package org.qortal.test; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; +import org.qortal.controller.BlockMinter; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PaymentTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.data.transaction.TransferPrivsTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.test.common.*; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.TransferPrivsTransaction; +import org.qortal.utils.NTP; + +import java.util.*; + +import static org.junit.Assert.*; +import static org.qortal.test.common.AccountUtils.fee; +import static org.qortal.transaction.Transaction.ValidationResult.*; + +public class SelfSponsorshipAlgoV1Tests extends Common { + + + @Before + public void beforeTest() throws DataException { + Common.useSettings("test-settings-v2-self-sponsorship-algo.json"); + NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); + } + + + @Test + public void testSingleSponsor() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob self sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(11, block.getBlockData().getOnlineAccountsCount()); + assertEquals(10, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMultipleSponsors() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(26, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees have no penalties + List chloeAndSponsees = new ArrayList<>(chloeSponsees); + chloeAndSponsees.add(chloeAccount); + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees have no penalties + List dilbertAndSponsees = new ArrayList<>(dilbertSponsees); + dilbertAndSponsees.add(dilbertAccount); + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees still have no penalties + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees still have no penalties + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMintBlockWithSignerPenalty() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + List onlineAccountsAliceSigner = new ArrayList<>(); + List onlineAccountsBobSigner = new ArrayList<>(); + + // Alice self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccountsAliceSigner.add(aliceSelfShare); + + // Bob self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccountsBobSigner.add(bobSelfShare); + + // Include Alice and Bob's online accounts in each other's arrays + onlineAccountsAliceSigner.add(bobSelfShare); + onlineAccountsBobSigner.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccountsAliceSigner.addAll(chloeSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccountsAliceSigner.addAll(dilbertSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Bob is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseeSelfShares); + onlineAccountsBobSigner.addAll(bobSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Bob is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Mint a block, but Bob is now an invalid signer because he is level 0 + block = BlockMinter.mintTestingBlockUnvalidated(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + // Block should be null as it's unable to be minted + assertNull(block); + + // Mint the same block with Alice as the signer, and this time it should be valid + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + // Block should NOT be null + assertNotNull(block); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMintBlockWithFounderSignerPenalty() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + List onlineAccountsAliceSigner = new ArrayList<>(); + List onlineAccountsBobSigner = new ArrayList<>(); + + // Alice self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccountsAliceSigner.add(aliceSelfShare); + + // Bob self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccountsBobSigner.add(bobSelfShare); + + // Include Alice and Bob's online accounts in each other's arrays + onlineAccountsAliceSigner.add(bobSelfShare); + onlineAccountsBobSigner.add(aliceSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccountsAliceSigner.addAll(aliceSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 9 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(19, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Alice is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); + onlineAccountsAliceSigner.addAll(aliceSponseeSelfShares); + onlineAccountsBobSigner.addAll(aliceSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees have no penalties + List aliceAndSponsees = new ArrayList<>(aliceSponsees); + aliceAndSponsees.add(aliceAccount); + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Alice is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice and her sponsees now have penalties + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(-5000000, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getLevel()); + + // Mint a block, but Alice is now an invalid signer because she has lost founder minting abilities + block = BlockMinter.mintTestingBlockUnvalidated(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + // Block should be null as it's unable to be minted + assertNull(block); + + // Mint the same block with Bob as the signer, and this time it should be valid + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + // Block should NOT be null + assertNotNull(block); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testOnlineAccountsWithPenalties() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + // Bob self share online + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccounts.add(bobSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(27, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that chloe's sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(chloeSponsees, block)); + + // Ensure that dilbert's sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(dilbertSponsees, block)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Mint another few blocks + while (block.getBlockData().getHeight() < 24) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(24, (int)block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees are NOT present in block's online accounts (due to penalties) + assertFalse(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that chloe's sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(chloeSponsees, block)); + + // Ensure that dilbert's sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(dilbertSponsees, block)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testFounderOnlineAccountsWithPenalties() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Bob self share online, and will be used to mint the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(bobSelfShare); + + // Alice self share online + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 9 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(19, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Alice is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); + onlineAccounts.addAll(aliceSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees have no penalties + List aliceAndSponsees = new ArrayList<>(aliceSponsees); + aliceAndSponsees.add(aliceAccount); + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Alice is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice and her sponsees now have penalties + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(-5000000, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getLevel()); + + // Ensure that alice and her sponsees don't have penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that alice and her sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(aliceAndSponsees, block)); + + // Mint another few blocks + while (block.getBlockData().getHeight() < 24) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(24, (int)block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees are NOT present in block's online accounts (due to penalties) + assertFalse(areAllAccountsPresentInBlock(aliceAndSponsees, block)); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyAccountCreateRewardShare() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(21, block.getBlockData().getOnlineAccountsCount()); + assertEquals(20, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Bob creates a valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob can no longer create a reward share transaction + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, AccountUtils.createRandomRewardShare(repository, bobAccount)); + + // ... but Chloe still can + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, chloeAccount)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Bob creates another valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyFounderCreateRewardShare() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Bob self share online, and will be used to mint the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(bobSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(21, block.getBlockData().getOnlineAccountsCount()); + assertEquals(20, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); + onlineAccounts.addAll(aliceSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Alice creates a valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, aliceAccount)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice now has a penalty + assertEquals(-5000000, (int) new Account(repository, aliceAccount.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + assertEquals(0, (int) new Account(repository, aliceAccount.getAddress()).getLevel()); + + // Alice can no longer create a reward share transaction + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, AccountUtils.createRandomRewardShare(repository, aliceAccount)); + + // ... but Bob still can + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Alice creates another valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, aliceAccount)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + /** + * This is a test to prove that Dilbert levels up from 6 to 7 in the same block that the self + * sponsorship algo runs. It is here to give some confidence in the following testPenaltyAccountLevelUp() + * test, in which we will test what happens if a penalty is applied or removed in the same block + * that an account would otherwise have leveled up. It also gives some confidence that the algo + * doesn't affect the levels of unflagged accounts. + * + * @throws DataException + */ + @Test + public void testNonPenaltyAccountLevelUp() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Dilbert sponsors 10 accounts + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Make sure Dilbert hasn't leveled up yet + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure Dilbert has leveled up + assertEquals(7, (int)dilbertAccount.getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Make sure Dilbert has returned to level 6 + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyAccountLevelUp() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Dilbert sponsors 10 accounts + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List dilbertSponseeSelfShares = AccountUtils.generateSelfShares(repository, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Dilbert then consolidates funds + consolidateFunds(repository, dilbertSponsees, dilbertAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Make sure Dilbert hasn't leveled up yet + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure Dilbert is now level 0 instead of 7 (due to penalty) + assertEquals(0, (int)dilbertAccount.getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Make sure Dilbert has returned to level 6 + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testDuplicateSponsors() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors THE SAME 10 accounts + for (PrivateKeyAccount bobSponsee : bobSponsees) { + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, chloeAccount, bobSponsee, 0, fee); + TransactionUtils.signAndImportValid(repository, transactionData, chloeAccount); + } + List chloeSponsees = new ArrayList<>(bobSponsees); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(26, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees also have penalties, as they relate to the same network of accounts + List chloeAndSponsees = new ArrayList<>(chloeSponsees); + chloeAndSponsees.add(chloeAccount); + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(-5000000, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees have no penalties + List dilbertAndSponsees = new ArrayList<>(dilbertSponsees); + dilbertAndSponsees.add(dilbertAccount); + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees still have no penalties again + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees still have no penalties + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsBeforeAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 18 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 18) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(18, (int) block.getBlockData().getHeight()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount.getLevel()); + + // Mint another block, so that the TRANSFER_PRIVS confirms + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob's sponsees are greater than level 0 + // Bob's account won't be, as he has transferred privs + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Ensure recipient account has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getLevel()); + + // TODO: check both recipients' sponsees + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that Bob's sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure recipient account has no penalty again and has a level greater than 0 + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + assertTrue(new Account(repository, recipientAccount.getAddress()).getLevel() > 0); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsInAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure recipient has no level again + assertNull(recipientAccount.getLevel()); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsAfterAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob then issues a TRANSFER_PRIVS, which should be invalid + Transaction transferPrivsTransaction = randomTransferPrivsTransaction(repository, bobAccount); + assertEquals(ACCOUNT_NOT_TRANSFERABLE, transferPrivsTransaction.isValid()); + + // Orphan last 2 blocks + BlockUtils.orphanLastBlock(repository); + BlockUtils.orphanLastBlock(repository); + + // TRANSFER_PRIVS should now be valid + transferPrivsTransaction = randomTransferPrivsTransaction(repository, bobAccount); + assertEquals(OK, transferPrivsTransaction.isValid()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testDoubleTransferPrivs() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 17 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 17) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(17, (int) block.getBlockData().getHeight()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount1 = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount1.getLevel()); + + // Bob and also sends some QORT to cover future transaction fees + // This mints another block, and the TRANSFER_PRIVS confirms + AccountUtils.pay(repository, bobAccount, recipientAccount1.getAddress(), 123456789L); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount1.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + + // The recipient account then issues a TRANSFER_PRIVS of their own + PrivateKeyAccount recipientAccount2 = randomTransferPrivs(repository, recipientAccount1); + + // Ensure recipientAccount2 has no level at this point (pre-confirmation) + assertNull(recipientAccount2.getLevel()); + + // Mint another block, so that the TRANSFER_PRIVS confirms + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient2 has inherited Bob's level, and recipient1 is at level 0 + assertTrue(recipientAccount2.getLevel() > 0); + assertEquals(0, (int)recipientAccount1.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob's sponsees are greater than level 0 + // Bob's account won't be, as he has transferred privs + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Ensure recipientAccount2 has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getLevel()); + + // Ensure recipientAccount1 has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getLevel()); + + // TODO: check recipient's sponsees + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that Bob's sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure recipientAccount1 has no penalty again and is level 0 + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getLevel()); + + // Ensure recipientAccount2 has no penalty again and has a level greater than 0 + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + assertTrue(new Account(repository, recipientAccount2.getAddress()).getLevel() > 0); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + + + private static PrivateKeyAccount randomTransferPrivs(Repository repository, PrivateKeyAccount senderAccount) throws DataException { + // Generate random recipient account + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + + BaseTransactionData baseTransactionData = new BaseTransactionData(NTP.getTime(), 0, senderAccount.getLastReference(), senderAccount.getPublicKey(), fee, null); + TransactionData transactionData = new TransferPrivsTransactionData(baseTransactionData, recipientAccount.getAddress()); + + TransactionUtils.signAndImportValid(repository, transactionData, senderAccount); + + return recipientAccount; + } + + private static TransferPrivsTransaction randomTransferPrivsTransaction(Repository repository, PrivateKeyAccount senderAccount) throws DataException { + // Generate random recipient account + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + + BaseTransactionData baseTransactionData = new BaseTransactionData(NTP.getTime(), 0, senderAccount.getLastReference(), senderAccount.getPublicKey(), fee, null); + TransactionData transactionData = new TransferPrivsTransactionData(baseTransactionData, recipientAccount.getAddress()); + + return new TransferPrivsTransaction(repository, transactionData); + } + + private boolean areAllAccountsPresentInBlock(List accounts, Block block) throws DataException { + for (PrivateKeyAccount bobSponsee : accounts) { + boolean foundOnlineAccountInBlock = false; + for (Block.ExpandedAccount expandedAccount : block.getExpandedAccounts()) { + if (expandedAccount.getRecipientAccount().getAddress().equals(bobSponsee.getAddress())) { + foundOnlineAccountInBlock = true; + break; + } + } + if (!foundOnlineAccountInBlock) { + return false; + } + } + return true; + } + + private static void consolidateFunds(Repository repository, List sponsees, PrivateKeyAccount sponsor) throws DataException { + for (PrivateKeyAccount sponsee : sponsees) { + for (int i = 0; i < 5; i++) { + // Generate new payments from sponsee to sponsor + TransactionData paymentData = new PaymentTransactionData(TestTransaction.generateBase(sponsee), sponsor.getAddress(), 1); + TransactionUtils.signAndImportValid(repository, paymentData, sponsee); // updates paymentData's signature + } + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/api/AdminApiTests.java b/src/test/java/org/qortal/test/api/AdminApiTests.java index b3e6da03..01f2ebc9 100644 --- a/src/test/java/org/qortal/test/api/AdminApiTests.java +++ b/src/test/java/org/qortal/test/api/AdminApiTests.java @@ -5,7 +5,7 @@ import static org.junit.Assert.*; import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.Before; import org.junit.Test; -import org.qortal.api.resource.AdminResource; +import org.qortal.api.restricted.resource.AdminResource; import org.qortal.repository.DataException; import org.qortal.settings.Settings; import org.qortal.test.common.ApiCommon; diff --git a/src/test/java/org/qortal/test/api/BlockApiTests.java b/src/test/java/org/qortal/test/api/BlockApiTests.java index 47d5318a..23e7b007 100644 --- a/src/test/java/org/qortal/test/api/BlockApiTests.java +++ b/src/test/java/org/qortal/test/api/BlockApiTests.java @@ -84,7 +84,7 @@ public class BlockApiTests extends ApiCommon { @Test public void testGetBlockRange() { - assertNotNull(this.blocksResource.getBlockRange(1, 1)); + assertNotNull(this.blocksResource.getBlockRange(1, 1, false, false)); List testValues = Arrays.asList(null, Integer.valueOf(1)); diff --git a/src/test/java/org/qortal/test/api/NamesApiTests.java b/src/test/java/org/qortal/test/api/NamesApiTests.java index 0e03b6a6..effdfea4 100644 --- a/src/test/java/org/qortal/test/api/NamesApiTests.java +++ b/src/test/java/org/qortal/test/api/NamesApiTests.java @@ -37,8 +37,8 @@ public class NamesApiTests extends ApiCommon { @Test public void testGetAllNames() { - assertNotNull(this.namesResource.getAllNames(null, null, null)); - assertNotNull(this.namesResource.getAllNames(1, 1, true)); + assertNotNull(this.namesResource.getAllNames(null, null, null, null)); + assertNotNull(this.namesResource.getAllNames(1L, 1, 1, true)); } @Test diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java index aabbe502..d2ee61c6 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java @@ -20,7 +20,7 @@ public class ArbitraryDataFileTests extends Common { @Test public void testSplitAndJoin() throws DataException { String dummyDataString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; - ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(dummyDataString.getBytes(), null); + ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(dummyDataString.getBytes(), null, false); assertTrue(arbitraryDataFile.exists()); assertEquals(62, arbitraryDataFile.size()); assertEquals("3eyjYjturyVe61grRX42bprGr3Cvw6ehTy4iknVnosDj", arbitraryDataFile.digest58()); @@ -50,7 +50,7 @@ public class ArbitraryDataFileTests extends Common { byte[] randomData = new byte[fileSize]; new Random().nextBytes(randomData); // No need for SecureRandom here - ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(randomData, null); + ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(randomData, null, false); assertTrue(arbitraryDataFile.exists()); assertEquals(fileSize, arbitraryDataFile.size()); String originalFileDigest = arbitraryDataFile.digest58(); diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java index 2042a30c..c05ceabf 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java @@ -24,6 +24,7 @@ import org.qortal.test.common.TransactionUtils; import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.RegisterNameTransaction; import org.qortal.utils.Base58; +import org.qortal.utils.ListUtils; import org.qortal.utils.NTP; import java.io.IOException; @@ -103,6 +104,7 @@ public class ArbitraryDataStorageCapacityTests extends Common { // Storage capacity should initially equal the total assertEquals(0, resourceListManager.getItemCountForList("followedNames")); + assertEquals(0, ListUtils.followedNamesCount()); long totalStorageCapacity = storageManager.getStorageCapacityIncludingThreshold(storageFullThreshold); assertEquals(totalStorageCapacity, storageManager.storageCapacityPerName(storageFullThreshold)); @@ -111,12 +113,16 @@ public class ArbitraryDataStorageCapacityTests extends Common { assertTrue(resourceListManager.addToList("followedNames", "Test2", false)); assertTrue(resourceListManager.addToList("followedNames", "Test3", false)); assertTrue(resourceListManager.addToList("followedNames", "Test4", false)); + assertTrue(resourceListManager.addToList("followedNames", "Test5", false)); + assertTrue(resourceListManager.addToList("followedNames", "Test6", false)); // Ensure the followed name count is correct - assertEquals(4, resourceListManager.getItemCountForList("followedNames")); + assertEquals(6, resourceListManager.getItemCountForList("followedNames")); + assertEquals(6, ListUtils.followedNamesCount()); // Storage space per name should be the total storage capacity divided by the number of names - long expectedStorageCapacityPerName = (long)(totalStorageCapacity / 4.0f); + // then multiplied by 4, to allow for names that don't use much space + long expectedStorageCapacityPerName = (long)(totalStorageCapacity / 6.0f) * 4L; assertEquals(expectedStorageCapacityPerName, storageManager.storageCapacityPerName(storageFullThreshold)); } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java index 9bf76127..49e645cf 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java @@ -246,7 +246,7 @@ public class ArbitraryDataStoragePolicyTests extends Common { Path path = Paths.get("src/test/resources/arbitrary/demo1"); ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( - repository, publicKey58, path, name, Method.PUT, Service.ARBITRARY_DATA, null, + repository, publicKey58, 0L, path, name, Method.PUT, Service.ARBITRARY_DATA, null, null, null, null, null); txnBuilder.build(); diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataTests.java index 573c2ae2..cfc656e1 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataTests.java @@ -111,8 +111,8 @@ public class ArbitraryDataTests extends Common { fail("Creating transaction should fail due to nonexistent PUT transaction"); } catch (DataException expectedException) { - assertEquals(String.format("Couldn't find PUT transaction for " + - "name %s, service %s and identifier ", name, service), expectedException.getMessage()); + assertTrue(expectedException.getMessage().contains(String.format("Couldn't find PUT transaction for " + + "name %s, service %s and identifier ", name, service))); } } @@ -358,7 +358,7 @@ public class ArbitraryDataTests extends Common { byte[] path1FileDigest = Crypto.digest(path1.toFile()); ArbitraryDataDigest path1DirectoryDigest = new ArbitraryDataDigest(path1.getParent()); path1DirectoryDigest.compute(); - ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, Method.PUT, service, alice); + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, Method.PUT, service, alice); // Now build the latest data state for this name ArbitraryDataReader arbitraryDataReader1 = new ArbitraryDataReader(name, ResourceIdType.NAME, service, identifier); diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryEncryptionTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryEncryptionTests.java new file mode 100644 index 00000000..2e4dc133 --- /dev/null +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryEncryptionTests.java @@ -0,0 +1,135 @@ +package org.qortal.test.arbitrary; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.arbitrary.ArbitraryDataDigest; +import org.qortal.crypto.AES; +import org.qortal.crypto.Crypto; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; +import org.qortal.utils.ZipUtils; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.*; + +public class ArbitraryEncryptionTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testEncryption() throws IOException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException { + String enclosingFolderName = "data"; + Path inputFilePath = Files.createTempFile("inputFile", null); + Path outputDirectory = Files.createTempDirectory("outputDirectory"); + Path outputFilePath = Paths.get(outputDirectory.toString(), enclosingFolderName); + inputFilePath.toFile().deleteOnExit(); + outputDirectory.toFile().deleteOnExit(); + + // Write random data to the input file + byte[] data = new byte[10]; + new Random().nextBytes(data); + Files.write(inputFilePath, data, StandardOpenOption.CREATE); + + assertTrue(Files.exists(inputFilePath)); + assertFalse(Files.exists(outputFilePath)); + + // Encrypt... + String algorithm = "AES/CBC/PKCS5Padding"; + SecretKey aesKey = AES.generateKey(256); + AES.encryptFile(algorithm, aesKey, inputFilePath.toString(), outputFilePath.toString()); + + assertTrue(Files.exists(inputFilePath)); + assertTrue(Files.exists(outputFilePath)); + + // Ensure encrypted file's hash differs from the original + assertFalse(Arrays.equals(Crypto.digest(inputFilePath.toFile()), Crypto.digest(outputFilePath.toFile()))); + + // Create paths for decrypting + Path decryptedDirectory = Files.createTempDirectory("decryptedDirectory"); + Path decryptedFile = Paths.get(decryptedDirectory.toString(), enclosingFolderName, inputFilePath.getFileName().toString()); + decryptedDirectory.toFile().deleteOnExit(); + assertFalse(Files.exists(decryptedFile)); + + // Now decrypt... + AES.decryptFile(algorithm, aesKey, outputFilePath.toString(), decryptedFile.toString()); + + // Ensure resulting file exists + assertTrue(Files.exists(decryptedFile)); + + // And make sure it matches the original input file + assertTrue(Arrays.equals(Crypto.digest(inputFilePath.toFile()), Crypto.digest(decryptedFile.toFile()))); + } + + @Test + public void testEncryptionSizeOverhead() throws IOException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException { + for (int size = 1; size < 256; size++) { + String enclosingFolderName = "data"; + Path inputFilePath = Files.createTempFile("inputFile", null); + Path outputDirectory = Files.createTempDirectory("outputDirectory"); + Path outputFilePath = Paths.get(outputDirectory.toString(), enclosingFolderName); + inputFilePath.toFile().deleteOnExit(); + outputDirectory.toFile().deleteOnExit(); + + // Write random data to the input file + byte[] data = new byte[size]; + new Random().nextBytes(data); + Files.write(inputFilePath, data, StandardOpenOption.CREATE); + + assertTrue(Files.exists(inputFilePath)); + assertFalse(Files.exists(outputFilePath)); + + // Ensure input file is the same size as the data + assertEquals(size, inputFilePath.toFile().length()); + + // Encrypt... + String algorithm = "AES/CBC/PKCS5Padding"; + SecretKey aesKey = AES.generateKey(256); + AES.encryptFile(algorithm, aesKey, inputFilePath.toString(), outputFilePath.toString()); + + assertTrue(Files.exists(inputFilePath)); + assertTrue(Files.exists(outputFilePath)); + + final long expectedSize = AES.getEncryptedFileSize(inputFilePath.toFile().length()); + System.out.println(String.format("Plaintext size: %d bytes, Ciphertext size: %d bytes", inputFilePath.toFile().length(), outputFilePath.toFile().length())); + + // Ensure encryption added a fixed amount of space to the output file + assertEquals(expectedSize, outputFilePath.toFile().length()); + + // Ensure encrypted file's hash differs from the original + assertFalse(Arrays.equals(Crypto.digest(inputFilePath.toFile()), Crypto.digest(outputFilePath.toFile()))); + + // Create paths for decrypting + Path decryptedDirectory = Files.createTempDirectory("decryptedDirectory"); + Path decryptedFile = Paths.get(decryptedDirectory.toString(), enclosingFolderName, inputFilePath.getFileName().toString()); + decryptedDirectory.toFile().deleteOnExit(); + assertFalse(Files.exists(decryptedFile)); + + // Now decrypt... + AES.decryptFile(algorithm, aesKey, outputFilePath.toString(), decryptedFile.toString()); + + // Ensure resulting file exists + assertTrue(Files.exists(decryptedFile)); + + // And make sure it matches the original input file + assertTrue(Arrays.equals(Crypto.digest(inputFilePath.toFile()), Crypto.digest(decryptedFile.toFile()))); + } + } + +} diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 4db8bdc7..b4c10fac 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -1,17 +1,35 @@ package org.qortal.test.arbitrary; +import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.Before; import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.arbitrary.ArbitraryDataFile; +import org.qortal.arbitrary.ArbitraryDataReader; +import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.arbitrary.misc.Service.ValidationResult; +import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.ArbitraryUtils; import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.RegisterNameTransaction; +import org.qortal.utils.Base58; +import java.io.BufferedWriter; +import java.io.FileWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.List; import java.util.Random; import static org.junit.Assert.*; @@ -102,77 +120,423 @@ public class ArbitraryServiceTests extends Common { } @Test - public void testValidQortalMetadata() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; + public void testValidateGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); - // Write to temp path - Path path = Files.createTempFile("testValidQortalMetadata", null); + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateGifRepository"); path.toFile().deleteOnExit(); - Files.write(path, dataString.getBytes(), StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.gif"), data, StandardOpenOption.CREATE); - Service service = Service.QORTAL_METADATA; + Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.OK, service.validate(path)); } @Test - public void testQortalMetadataMissingKeys() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"description\":\"Test description\", \"tags\":[\"test\"]}"; + public void testValidateSingleFileGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); - // Write to temp path - Path path = Files.createTempFile("testQortalMetadataMissingKeys", null); + // Write the data to a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileGifRepository"); path.toFile().deleteOnExit(); - Files.write(path, dataString.getBytes(), StandardOpenOption.CREATE); + Path imagePath = Paths.get(path.toString(), "image1.gif"); + Files.write(imagePath, data, StandardOpenOption.CREATE); - Service service = Service.QORTAL_METADATA; + Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.MISSING_KEYS, service.validate(path)); + + assertEquals(ValidationResult.OK, service.validate(imagePath)); } @Test - public void testQortalMetadataTooLarge() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; + public void testValidateMultiLayerGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); - // Generate some large data to go along with it - int largeDataSize = 11*1024; // Larger than allowed 10kiB - byte[] largeData = new byte[largeDataSize]; - new Random().nextBytes(largeData); - - // Write to temp path - Path path = Files.createTempDirectory("testQortalMetadataTooLarge"); + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiLayerGifRepository"); path.toFile().deleteOnExit(); - Files.write(Paths.get(path.toString(), "data"), dataString.getBytes(), StandardOpenOption.CREATE); - Files.write(Paths.get(path.toString(), "large_data"), largeData, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); - Service service = Service.QORTAL_METADATA; + Path subdirectory = Paths.get(path.toString(), "subdirectory"); + Files.createDirectories(subdirectory); + Files.write(Paths.get(subdirectory.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(subdirectory.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.EXCEEDS_SIZE_LIMIT, service.validate(path)); + + assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); } @Test - public void testMultipleFileMetadata() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; + public void testValidateEmptyGifRepository() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyGifRepository"); - // Generate some large data to go along with it - int otherDataSize = 1024; // Smaller than 10kiB limit - byte[] otherData = new byte[otherDataSize]; - new Random().nextBytes(otherData); - - // Write to temp path - Path path = Files.createTempDirectory("testMultipleFileMetadata"); - path.toFile().deleteOnExit(); - Files.write(Paths.get(path.toString(), "data"), dataString.getBytes(), StandardOpenOption.CREATE); - Files.write(Paths.get(path.toString(), "other_data"), otherData, StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; + Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There are multiple files, so we don't know which one to parse as JSON - assertEquals(ValidationResult.MISSING_KEYS, service.validate(path)); + assertEquals(ValidationResult.MISSING_DATA, service.validate(path)); } -} + @Test + public void testValidateInvalidGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateInvalidGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.jpg"), data, StandardOpenOption.CREATE); // Invalid extension + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); + } + + @Test + public void testValidatePublishedGifRepository() throws IOException, DataException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(path)); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = "test_identifier"; + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction + ArbitraryUtils.createAndMintTxn(repository, publicKey58, path, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice); + + // Build the latest data state for this name, and no exceptions should be thrown because validation passes + ArbitraryDataReader arbitraryDataReader1a = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader1a.loadSynchronously(true); + } + } + + @Test + public void testValidateQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateQChatAttachment"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "document.pdf"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(path)); + } + + @Test + public void testValidateSingleFileQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileQChatAttachment"); + path.toFile().deleteOnExit(); + Path filePath = Paths.get(path.toString(), "document.pdf"); + Files.write(filePath, data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + + @Test + public void testValidateInvalidQChatAttachmentFileExtension() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateInvalidQChatAttachmentFileExtension"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "application.exe"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); + } + + @Test + public void testValidateEmptyQChatAttachment() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyQChatAttachment"); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + + @Test + public void testValidateMultiLayerQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiLayerQChatAttachment"); + path.toFile().deleteOnExit(); + + Path subdirectory = Paths.get(path.toString(), "subdirectory"); + Files.createDirectories(subdirectory); + Files.write(Paths.get(subdirectory.toString(), "file.txt"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + + @Test + public void testValidateMultiFileQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiFileQChatAttachment"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "file1.txt"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "file2.txt"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + + @Test + public void testValidatePublishedQChatAttachment() throws IOException, DataException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileQChatAttachment"); + path.toFile().deleteOnExit(); + Path filePath = Paths.get(path.toString(), "document.pdf"); + Files.write(filePath, data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = "test_identifier"; + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction + ArbitraryUtils.createAndMintTxn(repository, publicKey58, filePath, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice); + + // Build the latest data state for this name, and no exceptions should be thrown because validation passes + ArbitraryDataReader arbitraryDataReader1a = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader1a.loadSynchronously(true); + } + } + + @Test + public void testValidateValidJson() throws IOException { + String invalidJsonString = "{\"test\": true, \"test2\": \"valid\"}"; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateValidJson"); + Path filePath = Paths.get(path.toString(), "test.json"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(invalidJsonString); + writer.close(); + + Service service = Service.JSON; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + @Test + public void testValidateInvalidJson() throws IOException { + String invalidJsonString = "{\"test\": true, \"test2\": invalid}"; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateInvalidJson"); + Path filePath = Paths.get(path.toString(), "test.json"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(invalidJsonString); + writer.close(); + + Service service = Service.JSON; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_CONTENT, service.validate(filePath)); + } + + @Test + public void testValidateEmptyJson() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyJson"); + + Service service = Service.JSON; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + + @Test + public void testValidPrivateData() throws IOException { + String dataString = "qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc="; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidPrivateData"); + Path filePath = Paths.get(path.toString(), "test"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(dataString); + writer.close(); + + Service service = Service.FILE_PRIVATE; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + + @Test + public void testValidPrivateGroupData() throws IOException { + String dataString = "qortalGroupEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc="; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidPrivateData"); + Path filePath = Paths.get(path.toString(), "test"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(dataString); + writer.close(); + + Service service = Service.FILE_PRIVATE; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + + @Test + public void testEncryptedData() throws IOException { + String dataString = "qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc="; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidPrivateData"); + Path filePath = Paths.get(path.toString(), "test"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(dataString); + writer.close(); + + // Validate a private service + Service service = Service.FILE_PRIVATE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.OK, service.validate(filePath)); + + // Validate a regular service + service = Service.FILE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.DATA_ENCRYPTED, service.validate(filePath)); + } + + @Test + public void testPlainTextData() throws IOException { + String dataString = "plaintext"; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testInvalidPrivateData"); + Path filePath = Paths.get(path.toString(), "test"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(dataString); + writer.close(); + + // Validate a private service + Service service = Service.FILE_PRIVATE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.DATA_NOT_ENCRYPTED, service.validate(filePath)); + + // Validate a regular service + service = Service.FILE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + + @Test + public void testGetPrivateServices() { + List privateServices = Service.privateServices(); + for (Service service : privateServices) { + assertTrue(service.isPrivate()); + } + } + + @Test + public void testGetPublicServices() { + List publicServices = Service.publicServices(); + for (Service service : publicServices) { + assertFalse(service.isPrivate()); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java index 357046fe..9ac73166 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java @@ -11,7 +11,9 @@ import org.qortal.arbitrary.ArbitraryDataReader; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.block.BlockChain; import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.data.arbitrary.ArbitraryResourceMetadata; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.repository.DataException; @@ -23,11 +25,16 @@ import org.qortal.test.common.TransactionUtils; import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.RegisterNameTransaction; import org.qortal.utils.Base58; +import org.qortal.utils.NTP; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.List; +import java.util.Random; import static org.junit.Assert.*; @@ -86,8 +93,8 @@ public class ArbitraryTransactionMetadataTests extends Common { String name = "TEST"; // Can be anything for this test String identifier = null; // Not used for this test Service service = Service.ARBITRARY_DATA; - int chunkSize = 1000; - int dataLength = 10; // Actual data length will be longer due to encryption + int chunkSize = 10000; + int dataLength = 1000; // Actual data length will be longer due to encryption String title = "Test title"; String description = "Test description"; @@ -101,8 +108,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -113,6 +121,7 @@ public class ArbitraryTransactionMetadataTests extends Common { assertEquals(description, arbitraryDataFile.getMetadata().getDescription()); assertEquals(tags, arbitraryDataFile.getMetadata().getTags()); assertEquals(category, arbitraryDataFile.getMetadata().getCategory()); + assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType()); // Now build the latest data state for this name ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ResourceIdType.NAME, service, identifier); @@ -136,8 +145,8 @@ public class ArbitraryTransactionMetadataTests extends Common { String name = "TEST"; // Can be anything for this test String identifier = null; // Not used for this test Service service = Service.ARBITRARY_DATA; - int chunkSize = 1000; - int dataLength = 10; // Actual data length will be longer due to encryption + int chunkSize = 10000; + int dataLength = 1000; // Actual data length will be longer due to encryption String title = "Test title"; String description = "Test description"; @@ -151,8 +160,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -163,6 +173,7 @@ public class ArbitraryTransactionMetadataTests extends Common { assertEquals(description, arbitraryDataFile.getMetadata().getDescription()); assertEquals(tags, arbitraryDataFile.getMetadata().getTags()); assertEquals(category, arbitraryDataFile.getMetadata().getCategory()); + assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType()); // Delete the file, to simulate that it hasn't been fetched from the network yet arbitraryDataFile.delete(); @@ -213,8 +224,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -225,6 +237,7 @@ public class ArbitraryTransactionMetadataTests extends Common { assertEquals(description, arbitraryDataFile.getMetadata().getDescription()); assertEquals(tags, arbitraryDataFile.getMetadata().getTags()); assertEquals(category, arbitraryDataFile.getMetadata().getCategory()); + assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType()); // Now build the latest data state for this name ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ResourceIdType.NAME, service, identifier); @@ -240,6 +253,48 @@ public class ArbitraryTransactionMetadataTests extends Common { } } + @Test + public void testUTF8Metadata() throws DataException, IOException, MissingDataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Example (modified) strings from real world content + String title = "Доля юаня в трансграничных Доля юаня в трансграничных"; + String description = "Когда рыночек порешал"; + List tags = Arrays.asList("Доля", "юаня", "трансграничных"); + Category category = Category.OTHER; + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Create PUT transaction + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, + title, description, tags, category); + + // Check the chunk count is correct + assertEquals(10, arbitraryDataFile.chunkCount()); + + // Check the metadata is correct + String expectedTrimmedTitle = "Доля юаня в трансграничных Доля юаня в тран"; + assertEquals(expectedTrimmedTitle, arbitraryDataFile.getMetadata().getTitle()); + assertEquals(description, arbitraryDataFile.getMetadata().getDescription()); + assertEquals(tags, arbitraryDataFile.getMetadata().getTags()); + assertEquals(category, arbitraryDataFile.getMetadata().getCategory()); + assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType()); + } + } + @Test public void testMetadataLengths() throws DataException, IOException, MissingDataException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -257,7 +312,7 @@ public class ArbitraryTransactionMetadataTests extends Common { Category category = Category.CRYPTOCURRENCY; String expectedTitle = "title Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent feugiat "; // 80 chars - String expectedDescription = "description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent feugiat pretium massa, non pulvinar mi pretium id. Ut gravida sapien vitae dui posuere tincidunt. Quisque in nibh est. Curabitur at blandit nunc, id aliquet neque. Nulla condimentum eget dolor a egestas. Vestibulum vel tincidunt ex. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Cras congue lacus in risus mattis suscipit. Quisque nisl eros, facilisis a lorem quis, vehicula biben"; // 500 chars + String expectedDescription = "description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent feugiat pretium massa, non pulvinar mi pretium id. Ut gravida sapien vitae dui posuere tincidunt. Quisque in nibh est. Curabitur at blandit nunc, id aliquet neque"; // 240 chars List expectedTags = Arrays.asList("tag 1", "tag 2", "tag 4", "tag 5", "tag 6"); // Register the name to Alice @@ -267,8 +322,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the metadata is correct @@ -279,6 +335,99 @@ public class ArbitraryTransactionMetadataTests extends Common { } } + @Test + public void testSingleFileList() throws DataException, IOException, MissingDataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Add a few files at multiple levels + byte[] data = new byte[1024]; + new Random().nextBytes(data); + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + Path file1 = Paths.get(path1.toString(), "file.txt"); + + // Create PUT transaction + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, file1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); + + // Check the file list metadata is correct + assertEquals(1, arbitraryDataFile.getMetadata().getFiles().size()); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("file.txt")); + + // Ensure the file list can be read back out again, when specified to be included + ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), true); + assertTrue(resourceMetadata.getFiles().contains("file.txt")); + + // Ensure it's not returned when specified to be excluded + ArbitraryResourceMetadata resourceMetadataSimple = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), false); + assertNull(resourceMetadataSimple.getFiles()); + + // Single-file resources should have a MIME type + assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType()); + } + } + + @Test + public void testMultipleFileList() throws DataException, IOException, MissingDataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Add a few files at multiple levels + byte[] data = new byte[1024]; + new Random().nextBytes(data); + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + Files.write(Paths.get(path1.toString(), "image1.jpg"), data, StandardOpenOption.CREATE); + + Path subdirectory = Paths.get(path1.toString(), "subdirectory"); + Files.createDirectories(subdirectory); + Files.write(Paths.get(subdirectory.toString(), "config.json"), data, StandardOpenOption.CREATE); + + // Create PUT transaction + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); + + // Check the file list metadata is correct + assertEquals(3, arbitraryDataFile.getMetadata().getFiles().size()); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("file.txt")); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("image1.jpg")); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("subdirectory/config.json")); + + // Ensure the file list can be read back out again, when specified to be included + ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), true); + assertTrue(resourceMetadata.getFiles().contains("file.txt")); + assertTrue(resourceMetadata.getFiles().contains("image1.jpg")); + assertTrue(resourceMetadata.getFiles().contains("subdirectory/config.json")); + + // Ensure it's not returned when specified to be excluded + // The entire object will be null because there is no metadata + ArbitraryResourceMetadata resourceMetadataSimple = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), false); + assertNull(resourceMetadataSimple); + + // Multi-file resources won't have a MIME type + assertEquals(null, arbitraryDataFile.getMetadata().getMimeType()); + } + } + @Test public void testExistingCategories() { // Matching categories should be correctly located diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java index 294e463e..089f0475 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java @@ -5,12 +5,20 @@ import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; import org.qortal.arbitrary.ArbitraryDataFile; +import org.qortal.arbitrary.ArbitraryDataReader; +import org.qortal.arbitrary.ArbitraryDataTransactionBuilder; import org.qortal.arbitrary.exception.MissingDataException; +import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.block.BlockChain; import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.crypto.Crypto; +import org.qortal.data.PaymentData; import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -20,11 +28,19 @@ import org.qortal.test.common.TransactionUtils; import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.RegisterNameTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.Base58; import org.qortal.utils.NTP; +import java.io.File; import java.io.IOException; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; import static org.junit.Assert.*; @@ -36,7 +52,7 @@ public class ArbitraryTransactionTests extends Common { } @Test - public void testDifficultyTooLow() throws IllegalAccessException, DataException, IOException, MissingDataException { + public void testNonceAndFee() throws IllegalAccessException, DataException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String publicKey58 = Base58.encode(alice.getPublicKey()); @@ -54,9 +70,11 @@ public class ArbitraryTransactionTests extends Common { // Set difficulty to 1 FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); - // Create PUT transaction + // Create PUT transaction, with a fee Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); - ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); + long fee = 10000000; // sufficient + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); // Check that nonce validation succeeds byte[] signature = arbitraryDataFile.getSignature(); @@ -67,7 +85,198 @@ public class ArbitraryTransactionTests extends Common { // Increase difficulty to 15 FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); - // Make sure the nonce validation fails + // Make sure that nonce validation still succeeds, as the fee has allowed us to avoid including a nonce + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testNonceAndLowFee() throws IllegalAccessException, DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee that is too low + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 9999999; // insufficient + boolean computeNonce = true; + boolean insufficientFeeDetected = false; + try { + ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + } + catch (DataException e) { + if (e.getMessage().contains("INSUFFICIENT_FEE")) { + insufficientFeeDetected = true; + } + } + + // Transaction should be invalid due to an insufficient fee + assertTrue(insufficientFeeDetected); + } + } + + @Test + public void testFeeNoNonce() throws IllegalAccessException, DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 10000000; // sufficient + boolean computeNonce = false; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds, even though it wasn't computed. This is because we have included a sufficient fee. + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure that nonce validation still succeeds, as the fee has allowed us to avoid including a nonce + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testLowFeeNoNonce() throws IllegalAccessException, DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee that is too low. Also, don't compute a nonce. + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 9999999; // insufficient + + ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( + repository, publicKey58, fee, path1, name, ArbitraryTransactionData.Method.PUT, service, identifier, null, null, null, null); + + txnBuilder.setChunkSize(chunkSize); + txnBuilder.build(); + ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); + Transaction.ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, alice); + + // Transaction should be invalid due to an insufficient fee + assertEquals(Transaction.ValidationResult.INSUFFICIENT_FEE, result); + } + } + + @Test + public void testZeroFeeNoNonce() throws IllegalAccessException, DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee that is too low. Also, don't compute a nonce. + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 0L; + + ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( + repository, publicKey58, fee, path1, name, ArbitraryTransactionData.Method.PUT, service, identifier, null, null, null, null); + + txnBuilder.setChunkSize(chunkSize); + txnBuilder.build(); + ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); + ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); + + // Transaction should be invalid + assertFalse(arbitraryTransaction.isSignatureValid()); + } + } + + @Test + public void testNonceAndFeeBeforeFeatureTrigger() throws IllegalAccessException, DataException, IOException { + // Use v2-minting settings, as these are pre-feature-trigger + Common.useSettings("test-settings-v2-minting.json"); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 10000000; // sufficient + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure the nonce validation fails, as we aren't allowing a fee to replace a nonce yet. // Note: there is a very tiny chance this could succeed due to being extremely lucky // and finding a high difficulty nonce in the first couple of cycles. It will be rare // enough that we shouldn't need to account for it. @@ -76,9 +285,322 @@ public class ArbitraryTransactionTests extends Common { // Reduce difficulty back to 1, to double check FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); assertTrue(transaction.isSignatureValid()); - } - } + @Test + public void testNonceAndInsufficientFeeBeforeFeatureTrigger() throws IllegalAccessException, DataException, IOException { + // Use v2-minting settings, as these are pre-feature-trigger + Common.useSettings("test-settings-v2-minting.json"); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 9999999; // insufficient + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // The transaction should be valid because we don't care about the fee (before the feature trigger) + assertEquals(Transaction.ValidationResult.OK, transaction.isValidUnconfirmed()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure the nonce validation fails, as we aren't allowing a fee to replace a nonce yet (and it was insufficient anyway) + // Note: there is a very tiny chance this could succeed due to being extremely lucky + // and finding a high difficulty nonce in the first couple of cycles. It will be rare + // enough that we shouldn't need to account for it. + assertFalse(transaction.isSignatureValid()); + + // Reduce difficulty back to 1, to double check + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testNonceAndZeroFeeBeforeFeatureTrigger() throws IllegalAccessException, DataException, IOException { + // Use v2-minting settings, as these are pre-feature-trigger + Common.useSettings("test-settings-v2-minting.json"); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 0L; + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // The transaction should be valid because we don't care about the fee (before the feature trigger) + assertEquals(Transaction.ValidationResult.OK, transaction.isValidUnconfirmed()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure the nonce validation fails, as we aren't allowing a fee to replace a nonce yet (and it was insufficient anyway) + // Note: there is a very tiny chance this could succeed due to being extremely lucky + // and finding a high difficulty nonce in the first couple of cycles. It will be rare + // enough that we shouldn't need to account for it. + assertFalse(transaction.isSignatureValid()); + + // Reduce difficulty back to 1, to double check + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testInvalidService() { + byte[] randomHash = new byte[32]; + new Random().nextBytes(randomHash); + + byte[] lastReference = new byte[64]; + new Random().nextBytes(lastReference); + + Long now = NTP.getTime(); + + final BaseTransactionData baseTransactionData = new BaseTransactionData(now, Group.NO_GROUP, + lastReference, randomHash, 0L, null); + final String name = "test"; + final String identifier = "test"; + final ArbitraryTransactionData.Method method = ArbitraryTransactionData.Method.PUT; + final ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.ZIP; + final int size = 999; + final int version = 5; + final int nonce = 0; + final byte[] secret = randomHash; + final ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH; + final byte[] digest = randomHash; + final byte[] metadataHash = null; + final List payments = new ArrayList<>(); + final int validService = Service.IMAGE.value; + final int invalidService = 99999999; + + // Try with valid service + ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, + version, validService, nonce, size, name, identifier, method, + secret, compression, digest, dataType, metadataHash, payments); + assertEquals(Service.IMAGE, transactionData.getService()); + + // Try with invalid service + transactionData = new ArbitraryTransactionData(baseTransactionData, + version, invalidService, nonce, size, name, identifier, method, + secret, compression, digest, dataType, metadataHash, payments); + assertNull(transactionData.getService()); + } + + @Test + public void testOnChainData() throws DataException, IOException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Set difficulty to 1 to speed up the tests + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 1000; + int dataLength = 239; // Max possible size. Becomes 256 bytes after encryption. + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Create PUT transaction + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength, true); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, + null, null, null, null); + + byte[] signature = arbitraryDataFile.getSignature(); + ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) repository.getTransactionRepository().fromSignature(signature); + + // Check that the data is published on chain + assertEquals(ArbitraryTransactionData.DataType.RAW_DATA, arbitraryTransactionData.getDataType()); + assertEquals(arbitraryDataFile.getBytes().length, arbitraryTransactionData.getData().length); + assertArrayEquals(arbitraryDataFile.getBytes(), arbitraryTransactionData.getData()); + + // Check that we have no chunks because the complete file is already less than the chunk size + assertEquals(0, arbitraryDataFile.chunkCount()); + + // Check that we have one file total - just the complete file (no chunks or metadata) + assertEquals(1, arbitraryDataFile.fileCount()); + + // Check the metadata isn't present + assertNull(arbitraryDataFile.getMetadata()); + + // Now build the latest data state for this name + ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader.loadSynchronously(true); + + // Filename will be "data" because it's been held as raw bytes in the transaction, + // so there is nowhere to store the original filename + File outputFile = Paths.get(arbitraryDataReader.getFilePath().toString(), "data").toFile(); + + assertArrayEquals(Crypto.digest(outputFile), Crypto.digest(path1.toFile())); + } + } + + @Test + public void testOnChainDataWithMetadata() throws DataException, IOException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Set difficulty to 1 to speed up the tests + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 1000; + int dataLength = 239; // Max possible size. Becomes 256 bytes after encryption. + + String title = "Test title"; + String description = "Test description"; + List tags = Arrays.asList("Test", "tag", "another tag"); + Category category = Category.QORTAL; + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Create PUT transaction + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength, true); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, + title, description, tags, category); + + byte[] signature = arbitraryDataFile.getSignature(); + ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) repository.getTransactionRepository().fromSignature(signature); + + // Check that the data is published on chain + assertEquals(ArbitraryTransactionData.DataType.RAW_DATA, arbitraryTransactionData.getDataType()); + assertEquals(arbitraryDataFile.getBytes().length, arbitraryTransactionData.getData().length); + assertArrayEquals(arbitraryDataFile.getBytes(), arbitraryTransactionData.getData()); + + // Check that we have no chunks because the complete file is already less than the chunk size + assertEquals(0, arbitraryDataFile.chunkCount()); + + // Check that we have two files total - one for the complete file, and the other for the metadata + assertEquals(2, arbitraryDataFile.fileCount()); + + // Check the metadata is correct + assertEquals(title, arbitraryDataFile.getMetadata().getTitle()); + assertEquals(description, arbitraryDataFile.getMetadata().getDescription()); + assertEquals(tags, arbitraryDataFile.getMetadata().getTags()); + assertEquals(category, arbitraryDataFile.getMetadata().getCategory()); + assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType()); + + // Now build the latest data state for this name + ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader.loadSynchronously(true); + + // Filename will be "data" because it's been held as raw bytes in the transaction, + // so there is nowhere to store the original filename + File outputFile = Paths.get(arbitraryDataReader.getFilePath().toString(), "data").toFile(); + + assertArrayEquals(Crypto.digest(outputFile), Crypto.digest(path1.toFile())); + } + } + + @Test + public void testOffChainData() throws DataException, IOException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Set difficulty to 1 to speed up the tests + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 1000; + int dataLength = 240; // Min possible size. Becomes 257 bytes after encryption. + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Create PUT transaction + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength, true); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, + null, null, null, null); + + byte[] signature = arbitraryDataFile.getSignature(); + ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) repository.getTransactionRepository().fromSignature(signature); + + // Check that the data is published on chain + assertEquals(ArbitraryTransactionData.DataType.DATA_HASH, arbitraryTransactionData.getDataType()); + assertEquals(TransactionTransformer.SHA256_LENGTH, arbitraryTransactionData.getData().length); + assertFalse(Arrays.equals(arbitraryDataFile.getBytes(), arbitraryTransactionData.getData())); + + // Check that we have no chunks because the complete file is already less than the chunk size + assertEquals(0, arbitraryDataFile.chunkCount()); + + // Check that we have one file total - just the complete file (no chunks or metadata) + assertEquals(1, arbitraryDataFile.fileCount()); + + // Check the metadata isn't present + assertNull(arbitraryDataFile.getMetadata()); + + // Now build the latest data state for this name + ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader.loadSynchronously(true); + + // File content should match original file + File outputFile = Paths.get(arbitraryDataReader.getFilePath().toString(), "file.txt").toFile(); + assertArrayEquals(Crypto.digest(outputFile), Crypto.digest(path1.toFile())); + } + } } diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 8ef4c774..8441731f 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -2,29 +2,20 @@ package org.qortal.test.at; import static org.junit.Assert.*; -import java.nio.ByteBuffer; import java.util.List; -import org.ciyam.at.CompilationException; import org.ciyam.at.MachineState; -import org.ciyam.at.OpCode; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.test.common.AtUtils; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; import org.qortal.transaction.DeployAtTransaction; public class AtRepositoryTests extends Common { @@ -76,7 +67,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); repository.getATRepository().trimAtStates(2, maxHeight, 1000); ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); @@ -130,7 +121,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = blockchainHeight; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); // COMMIT to check latest AT states persist / TEMPORARY table interaction repository.saveChanges(); @@ -163,8 +154,8 @@ public class AtRepositoryTests extends Common { int maxTrimHeight = blockchainHeight - 4; Integer testHeight = maxTrimHeight + 1; - // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + // Trim AT state data (using a max height of maxTrimHeight + 1, so it is beyond the trimmed range) + repository.getATRepository().rebuildLatestAtStates(maxTrimHeight + 1); repository.saveChanges(); repository.getATRepository().trimAtStates(2, maxTrimHeight, 1000); @@ -333,7 +324,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); repository.getATRepository().trimAtStates(2, maxHeight, 1000); List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index 0d0b6d6a..bdfd124b 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -8,7 +8,6 @@ import java.util.*; import com.google.common.primitives.Longs; import org.qortal.account.PrivateKeyAccount; -import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.crypto.Qortal25519Extras; import org.qortal.data.network.OnlineAccountData; @@ -19,6 +18,7 @@ import org.qortal.data.transaction.TransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.transaction.Transaction; import org.qortal.transform.Transformer; import org.qortal.utils.Amounts; @@ -49,10 +49,10 @@ public class AccountUtils { public static TransactionData createRewardShare(Repository repository, String minter, String recipient, int sharePercent) throws DataException { PrivateKeyAccount mintingAccount = Common.getTestAccount(repository, minter); PrivateKeyAccount recipientAccount = Common.getTestAccount(repository, recipient); - return createRewardShare(repository, mintingAccount, recipientAccount, sharePercent); + return createRewardShare(repository, mintingAccount, recipientAccount, sharePercent, fee); } - public static TransactionData createRewardShare(Repository repository, PrivateKeyAccount mintingAccount, PrivateKeyAccount recipientAccount, int sharePercent) throws DataException { + public static TransactionData createRewardShare(Repository repository, PrivateKeyAccount mintingAccount, PrivateKeyAccount recipientAccount, int sharePercent, long fee) throws DataException { byte[] reference = mintingAccount.getLastReference(); long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1; @@ -78,7 +78,7 @@ public class AccountUtils { } public static byte[] rewardShare(Repository repository, PrivateKeyAccount minterAccount, PrivateKeyAccount recipientAccount, int sharePercent) throws DataException { - TransactionData transactionData = createRewardShare(repository, minterAccount, recipientAccount, sharePercent); + TransactionData transactionData = createRewardShare(repository, minterAccount, recipientAccount, sharePercent, fee); TransactionUtils.signAndMint(repository, transactionData, minterAccount); byte[] rewardSharePrivateKey = minterAccount.getRewardSharePrivateKey(recipientAccount.getPublicKey()); @@ -86,6 +86,61 @@ public class AccountUtils { return rewardSharePrivateKey; } + public static List generateSponsorshipRewardShares(Repository repository, PrivateKeyAccount sponsorAccount, int accountsCount) throws DataException { + final int sharePercent = 0; + Random random = new Random(); + + List sponsees = new ArrayList<>(); + for (int i = 0; i < accountsCount; i++) { + + // Generate random sponsee account + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + sponsees.add(sponseeAccount); + + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, sponsorAccount, sponseeAccount, sharePercent, fee); + TransactionUtils.signAndImportValid(repository, transactionData, sponsorAccount); + } + + return sponsees; + } + + public static Transaction.ValidationResult createRandomRewardShare(Repository repository, PrivateKeyAccount account) throws DataException { + // Bob attempts to create a reward share transaction + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + TransactionData transactionData = createRewardShare(repository, account, sponseeAccount, 0, fee); + return TransactionUtils.signAndImport(repository, transactionData, account); + } + + public static List generateSelfShares(Repository repository, List accounts) throws DataException { + final int sharePercent = 0; + + for (PrivateKeyAccount account : accounts) { + // Create reward-share + TransactionData transactionData = createRewardShare(repository, account, account, sharePercent, 0L); + TransactionUtils.signAndImportValid(repository, transactionData, account); + } + + return toRewardShares(repository, null, accounts); + } + + public static List toRewardShares(Repository repository, PrivateKeyAccount parentAccount, List accounts) { + List rewardShares = new ArrayList<>(); + + for (PrivateKeyAccount account : accounts) { + PrivateKeyAccount sponsor = (parentAccount != null) ? parentAccount : account; + byte[] rewardSharePrivateKey = sponsor.getRewardSharePrivateKey(account.getPublicKey()); + PrivateKeyAccount rewardShareAccount = new PrivateKeyAccount(repository, rewardSharePrivateKey); + rewardShares.add(rewardShareAccount); + } + + return rewardShares; + } + public static Map> getBalances(Repository repository, long... assetIds) throws DataException { Map> balances = new HashMap<>(); @@ -124,8 +179,6 @@ public class AccountUtils { long timestamp = System.currentTimeMillis(); byte[] timestampBytes = Longs.toByteArray(timestamp); - final boolean mempowActive = timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - for (int a = 0; a < numAccounts; ++a) { byte[] privateKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; SECURE_RANDOM.nextBytes(privateKey); @@ -135,7 +188,7 @@ public class AccountUtils { byte[] signature = signForAggregation(privateKey, timestampBytes); - Integer nonce = mempowActive ? new Random().nextInt(500000) : null; + Integer nonce = new Random().nextInt(500000); onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey, nonce)); } diff --git a/src/test/java/org/qortal/test/common/ArbitraryUtils.java b/src/test/java/org/qortal/test/common/ArbitraryUtils.java index 81abf47f..e08eb0ac 100644 --- a/src/test/java/org/qortal/test/common/ArbitraryUtils.java +++ b/src/test/java/org/qortal/test/common/ArbitraryUtils.java @@ -5,10 +5,12 @@ import org.qortal.arbitrary.ArbitraryDataFile; import org.qortal.arbitrary.ArbitraryDataTransactionBuilder; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.block.BlockChain; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.transaction.Transaction; +import org.qortal.utils.NTP; import java.io.BufferedWriter; import java.io.File; @@ -20,40 +22,40 @@ import java.nio.file.Paths; import java.util.List; import java.util.Random; -import static org.junit.Assert.assertEquals; - public class ArbitraryUtils { public static ArbitraryDataFile createAndMintTxn(Repository repository, String publicKey58, Path path, String name, String identifier, ArbitraryTransactionData.Method method, Service service, PrivateKeyAccount account, int chunkSize) throws DataException { + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); return ArbitraryUtils.createAndMintTxn(repository, publicKey58, path, name, identifier, method, service, - account, chunkSize, null, null, null, null); + account, chunkSize, fee, false, null, null, null, null); } public static ArbitraryDataFile createAndMintTxn(Repository repository, String publicKey58, Path path, String name, String identifier, ArbitraryTransactionData.Method method, Service service, PrivateKeyAccount account, - int chunkSize, String title, String description, List tags, Category category) throws DataException { + int chunkSize, long fee, boolean computeNonce, + String title, String description, List tags, Category category) throws DataException { ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( - repository, publicKey58, path, name, method, service, identifier, title, description, tags, category); + repository, publicKey58, fee, path, name, method, service, identifier, title, description, tags, category); txnBuilder.setChunkSize(chunkSize); txnBuilder.build(); - txnBuilder.computeNonce(); + if (computeNonce) { + txnBuilder.computeNonce(); + } ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); Transaction.ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, account); - assertEquals(Transaction.ValidationResult.OK, result); + if (result != Transaction.ValidationResult.OK) { + throw new DataException(String.format("Arbitrary transaction invalid: %s", result.toString())); + } BlockUtils.mintBlock(repository); // We need a new ArbitraryDataFile instance because the files will have been moved to the signature's folder - byte[] hash = txnBuilder.getArbitraryDataFile().getHash(); - byte[] signature = transactionData.getSignature(); - ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); - arbitraryDataFile.setMetadataHash(transactionData.getMetadataHash()); - - return arbitraryDataFile; + // Or, it may now be using RAW_DATA instead of a hash + return ArbitraryDataFile.fromTransactionData(transactionData); } public static ArbitraryDataFile createAndMintTxn(Repository repository, String publicKey58, Path path, String name, String identifier, @@ -65,6 +67,17 @@ public class ArbitraryUtils { } public static Path generateRandomDataPath(int length) throws IOException { + return generateRandomDataPath(length, false); + } + + /** + * Generate random data, held in a single file within a directory + * @param length - size of file to create + * @param returnFilePath - if true, the file's path is returned. If false, the outer directory's path is returned. + * @return - path to file or directory, depending on the "returnFilePath" boolean + * @throws IOException + */ + public static Path generateRandomDataPath(int length, boolean returnFilePath) throws IOException { // Create a file in a random temp directory Path tempDir = Files.createTempDirectory("generateRandomDataPath"); File file = new File(Paths.get(tempDir.toString(), "file.txt").toString()); @@ -81,6 +94,10 @@ public class ArbitraryUtils { file1Writer.newLine(); file1Writer.close(); + if (returnFilePath) { + return file.toPath(); + } + return tempDir; } diff --git a/src/test/java/org/qortal/test/common/BlockUtils.java b/src/test/java/org/qortal/test/common/BlockUtils.java index 3077b65b..ab57dadf 100644 --- a/src/test/java/org/qortal/test/common/BlockUtils.java +++ b/src/test/java/org/qortal/test/common/BlockUtils.java @@ -20,6 +20,15 @@ public class BlockUtils { return BlockMinter.mintTestingBlock(repository, mintingAccount); } + /** Mints multiple blocks using "alice-reward-share" test account, and returns the final block. */ + public static Block mintBlocks(Repository repository, int count) throws DataException { + Block block = null; + for (int i=0; i payments = new ArrayList<>(); payments.add(new PaymentData(recipient, assetId, amount)); - return new ArbitraryTransactionData(generateBase(account), version, service, nonce, size,name, identifier, + return new ArbitraryTransactionData(generateBase(account), version, service.value, nonce, size,name, identifier, method, secret, compression, data, dataType, metadataHash, payments); } diff --git a/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java new file mode 100644 index 00000000..bab1f1a0 --- /dev/null +++ b/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java @@ -0,0 +1,40 @@ +package org.qortal.test.common.transaction; + +import org.qortal.account.PrivateKeyAccount; +import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +import java.util.Random; + +public class ChatTestTransaction extends TestTransaction { + + public static TransactionData randomTransaction(Repository repository, PrivateKeyAccount account, boolean wantValid) throws DataException { + Random random = new Random(); + byte[] orderId = new byte[64]; + random.nextBytes(orderId); + + String sender = Crypto.toAddress(account.getPublicKey()); + int nonce = 1234567; + + // Generate random recipient + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + String recipient = Crypto.toAddress(recipientAccount.getPublicKey()); + + byte[] chatReference = new byte[64]; + random.nextBytes(chatReference); + + byte[] data = new byte[4000]; + random.nextBytes(data); + + boolean isText = true; + boolean isEncrypted = true; + + return new ChatTransactionData(generateBase(account), sender, nonce, recipient, chatReference, data, isText, isEncrypted); + } + +} diff --git a/src/test/java/org/qortal/test/common/transaction/TestTransaction.java b/src/test/java/org/qortal/test/common/transaction/TestTransaction.java index 11fdf58e..b580ecd3 100644 --- a/src/test/java/org/qortal/test/common/transaction/TestTransaction.java +++ b/src/test/java/org/qortal/test/common/transaction/TestTransaction.java @@ -7,13 +7,15 @@ import org.qortal.block.BlockChain; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; +import org.qortal.utils.NTP; public abstract class TestTransaction { protected static final Random random = new Random(); public static BaseTransactionData generateBase(PrivateKeyAccount account, int txGroupId) throws DataException { - return new BaseTransactionData(System.currentTimeMillis(), txGroupId, account.getLastReference(), account.getPublicKey(), BlockChain.getInstance().getUnitFee(), null); + long timestamp = System.currentTimeMillis(); + return new BaseTransactionData(timestamp, txGroupId, account.getLastReference(), account.getPublicKey(), BlockChain.getInstance().getUnitFeeAtTimestamp(timestamp), null); } public static BaseTransactionData generateBase(PrivateKeyAccount account) throws DataException { diff --git a/src/test/java/org/qortal/test/crosschain/ACCTTests.java b/src/test/java/org/qortal/test/crosschain/ACCTTests.java new file mode 100644 index 00000000..6af27a96 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/ACCTTests.java @@ -0,0 +1,790 @@ +package org.qortal.test.crosschain; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.Amounts; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.function.Function; + +import static org.junit.Assert.*; + +public abstract class ACCTTests extends Common { + + public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); + public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a + public static final int tradeTimeout = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long foreignAmount = 864200L; // 0.00864200 foreign units + + protected static final Random RANDOM = new Random(); + + protected abstract byte[] getPublicKey(); + + protected abstract byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout); + + protected abstract ACCT getInstance(); + + protected abstract int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA); + + protected abstract byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout); + + protected abstract byte[] buildRedeemMessage(byte[] secretA, String address); + + protected abstract byte[] getCodeBytesHash(); + + protected abstract String getSymbol(); + + protected abstract String getName(); + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testCompile() { + PrivateKeyAccount tradeAccount = createTradeAccount(null); + + byte[] creationBytes = buildQortalAT(tradeAccount.getAddress(), getPublicKey(), redeemAmount, foreignAmount, tradeTimeout); + assertNotNull(creationBytes); + + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + } + + + @Test + public void testDeploy() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = fundingAmount; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + expectedBalance = deployersInitialBalance; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = 0; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancel() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = getInstance().buildCancelMessage(deployer.getAddress()); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Check balances + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance - messageFee; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancelInvalidLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Instead of sending creator's address to AT, send too-short/invalid message + byte[] messageData = new byte[7]; + RANDOM.nextBytes(messageData); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner Qortal address was extracted correctly + assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); + + // Check trade partner's Foreign Coin PKH was extracted correctly + assertTrue(Arrays.equals(getPublicKey(), tradeData.partnerForeignPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) + @SuppressWarnings("unused") + @Test + public void testIncorrectTradeSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + BlockUtils.mintBlock(repository); + + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(AcctMode.OFFERING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testAutomaticTradeRefund() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + // Check refund + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REFUNDED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REFUNDED, tradeData.mode); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account + messageData = buildRedeemMessage(secretA, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REDEEMED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REDEEMED, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); + + // Orphan redeem + BlockUtils.orphanLastBlock(repository); + + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); + + // Check AT state + ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); + + assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretIncorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, but from wrong account + messageData = buildRedeemMessage(secretA, partner.getAddress()); + messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testIncorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send incorrect secret to AT, from correct account + byte[] wrongSecret = new byte[32]; + RANDOM.nextBytes(wrongSecret); + messageData = buildRedeemMessage(wrongSecret, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length + messageData = Bytes.concat(secretA); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should be in TRADING mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testDescribeDeployed() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + List executableAts = repository.getATRepository().getAllExecutableATs(); + + for (ATData atData : executableAts) { + String atAddress = atData.getATAddress(); + byte[] codeBytes = atData.getCodeBytes(); + byte[] codeHash = Crypto.digest(codeBytes); + + System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", + atAddress, + codeBytes.length, + (codeBytes.length != 1 ? "s": ""), + HashCode.fromBytes(codeHash))); + + // Not one of ours? + if (!Arrays.equals(codeHash, getCodeBytesHash())) + continue; + + describeAt(repository, atAddress); + } + } + } + + protected int calcTestLockTimeA(long messageTimestamp) { + return (int) (messageTimestamp / 1000L + tradeTimeout * 60); + } + + protected DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = buildQortalAT(tradeAddress, getPublicKey(), redeemAmount, foreignAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-" + getSymbol() + " cross-chain trade"; + String description = String.format("Qortal-" + getName() + " cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-" + getSymbol() + " ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + protected MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + + protected void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = tradeTimeout / 2 + 1; // close enough + + // AT should automatically refund deployer after 'refundTimeout' blocks + for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) + BlockUtils.mintBlock(repository); + + // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + } + + protected void describeAt(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + + Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); + int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); + + System.out.print(String.format("%s:\n" + + "\tmode: %s\n" + + "\tcreator: %s,\n" + + "\tcreation timestamp: %s,\n" + + "\tcurrent balance: %s QORT,\n" + + "\tis finished: %b,\n" + + "\tredeem payout: %s QORT,\n" + + "\texpected " + getName() + ": %s " + getSymbol() + ",\n" + + "\tcurrent block height: %d,\n", + tradeData.qortalAtAddress, + tradeData.mode, + tradeData.qortalCreator, + epochMilliFormatter.apply(tradeData.creationTimestamp), + Amounts.prettyAmount(tradeData.qortBalance), + atData.getIsFinished(), + Amounts.prettyAmount(tradeData.qortAmount), + Amounts.prettyAmount(tradeData.expectedForeignAmount), + currentBlockHeight)); + + describeRefundAt(tradeData, epochMilliFormatter); + } + + protected void describeRefundAt(CrossChainTradeData tradeData, Function epochMilliFormatter) { + if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { + System.out.println(String.format("\trefund timeout: %d minutes,\n" + + "\trefund height: block %d,\n" + + "\tHASH160 of secret-A: %s,\n" + + "\t" + getName() + " P2SH-A nLockTime: %d (%s),\n" + + "\ttrade partner: %s\n" + + "\tpartner's receiving address: %s", + tradeData.refundTimeout, + tradeData.tradeRefundHeight, + HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.qortalPartnerAddress, + tradeData.qortalPartnerReceivingAddress)); + } + } + + protected PrivateKeyAccount createTradeAccount(Repository repository) { + // We actually use a known test account with funds to avoid PoW compute + return Common.getTestAccount(repository, "alice"); + } +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java index 07a01ce2..684f1cd6 100644 --- a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java @@ -1,115 +1,59 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import org.qortal.crosschain.Bitcoin; -import org.qortal.crosschain.ForeignBlockchainException; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; +import org.qortal.crosschain.Bitcoiny; -public class BitcoinTests extends Common { +public class BitcoinTests extends BitcoinyTests { - private Bitcoin bitcoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - bitcoin = Bitcoin.getInstance(); + @Override + protected String getCoinName() { + return "Bitcoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "BTC"; + } + + @Override + protected Bitcoiny getCoin() { + return Bitcoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Bitcoin.resetForTesting(); - bitcoin = null; + } + + @Override + protected String getDeterministicKey58() { + return "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + } + + @Override + protected String getRecipient() { + return "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; } @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - System.out.println(String.format("Starting BTC instance...")); - System.out.println(String.format("BTC instance started")); - - long before = System.currentTimeMillis(); - System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); - - System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); - } + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") + public void testGetMedianBlockTime() {} @Test - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); - - assertNotNull(secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") + public void testFindHtlcSecret() {} @Test - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = bitcoin.buildSpend(xprv58, recipient, amount); - assertNotNull(transaction); - - // Check spent key caching doesn't affect outcome - - transaction = bitcoin.buildSpend(xprv58, recipient, amount); - assertNotNull(transaction); - } + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") + public void testBuildSpend() {} @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - Long balance = bitcoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(bitcoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = bitcoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(bitcoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") + public void testGetWalletBalance() {} @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String address = bitcoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") + public void testGetUnusedReceiveAddress() {} } diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java new file mode 100644 index 00000000..b29fffd4 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java @@ -0,0 +1,130 @@ +package org.qortal.test.crosschain; + +import org.bitcoinj.core.Transaction; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +import java.util.Arrays; + +import static org.junit.Assert.*; + +public abstract class BitcoinyTests extends Common { + + protected Bitcoiny bitcoiny; + + protected abstract String getCoinName(); + + protected abstract String getCoinSymbol(); + + protected abstract Bitcoiny getCoin(); + + protected abstract void resetCoinForTesting(); + + protected abstract String getDeterministicKey58(); + + protected abstract String getRecipient(); + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + bitcoiny = getCoin(); + } + + @After + public void afterTest() { + resetCoinForTesting(); + bitcoiny = null; + } + + @Test + public void testGetMedianBlockTime() throws ForeignBlockchainException { + System.out.println(String.format("Starting " + getCoinSymbol() + " instance...")); + System.out.println(String.format(getCoinSymbol() + " instance started")); + + long before = System.currentTimeMillis(); + System.out.println(String.format(getCoinName() + " median blocktime: %d", bitcoiny.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format(getCoinName() + " median blocktime: %d", bitcoiny.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + makeGetMedianBlockTimeAssertions(firstPeriod, secondPeriod); + } + + public void makeGetMedianBlockTimeAssertions(long firstPeriod, long secondPeriod) { + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoiny, p2shAddress); + + assertNotNull(secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + public void testBuildSpend() { + String xprv58 = getDeterministicKey58(); + + String recipient = getRecipient(); + long amount = 1000L; + + Transaction transaction = bitcoiny.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + + // Check spent key caching doesn't affect outcome + + transaction = bitcoiny.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + } + + @Test + public void testGetWalletBalance() throws ForeignBlockchainException { + String xprv58 = getDeterministicKey58(); + + Long balance = bitcoiny.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(bitcoiny.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = bitcoiny.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(bitcoiny.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = getDeterministicKey58(); + + String address = bitcoiny.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/DigibyteTests.java b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java index dbe81c82..d95f1bd5 100644 --- a/src/test/java/org/qortal/test/crosschain/DigibyteTests.java +++ b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java @@ -1,115 +1,48 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Digibyte; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -public class DigibyteTests extends Common { +public class DigibyteTests extends BitcoinyTests { - private Digibyte digibyte; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - digibyte = Digibyte.getInstance(); + @Override + protected String getCoinName() { + return "Digibyte"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "DGB"; + } + + @Override + protected Bitcoiny getCoin() { + return Digibyte.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Digibyte.resetForTesting(); - digibyte = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Digibyte median blocktime: %d", digibyte.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; + } - System.out.println(String.format("Digibyte median blocktime: %d", digibyte.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(digibyte, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + public void testBuildSpend() {} - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = digibyte.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = digibyte.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; - - Long balance = digibyte.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(digibyte.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = digibyte.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(digibyte.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; - - String address = digibyte.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - -} diff --git a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java index 6c070d09..62982437 100644 --- a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java @@ -1,115 +1,47 @@ package org.qortal.test.crosschain; -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Dogecoin; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -import java.util.Arrays; +public class DogecoinTests extends BitcoinyTests { -import static org.junit.Assert.*; - -public class DogecoinTests extends Common { - - private Dogecoin dogecoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - dogecoin = Dogecoin.getInstance(); + @Override + protected String getCoinName() { + return "Dogecoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "DOGE"; + } + + @Override + protected Bitcoiny getCoin() { + return Dogecoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Dogecoin.resetForTesting(); - dogecoin = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Dogecoin median blocktime: %d", dogecoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; + } - System.out.println(String.format("Dogecoin median blocktime: %d", dogecoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return null; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(dogecoin, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") - public void testBuildSpend() { - String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; - - String recipient = "DP1iFao33xdEPa5vaArpj7sykfzKNeiJeX"; - long amount = 1000L; - - Transaction transaction = dogecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = dogecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; - - Long balance = dogecoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(dogecoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = dogecoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(dogecoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; - - String address = dogecoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + public void testBuildSpend() {} } diff --git a/src/test/java/org/qortal/test/crosschain/HtlcTests.java b/src/test/java/org/qortal/test/crosschain/HtlcTests.java index 75b290bf..3f3678f7 100644 --- a/src/test/java/org/qortal/test/crosschain/HtlcTests.java +++ b/src/test/java/org/qortal/test/crosschain/HtlcTests.java @@ -8,6 +8,7 @@ import org.junit.Ignore; import org.junit.Test; import org.qortal.crosschain.Bitcoin; import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; import org.qortal.crypto.Crypto; import org.qortal.crosschain.BitcoinyHTLC; import org.qortal.repository.DataException; @@ -18,17 +19,19 @@ import com.google.common.primitives.Longs; public class HtlcTests extends Common { private Bitcoin bitcoin; + private Litecoin litecoin; @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); // TestNet3 bitcoin = Bitcoin.getInstance(); + litecoin = Litecoin.getInstance(); } @After public void afterTest() { Bitcoin.resetForTesting(); - bitcoin = null; + litecoin = null; } @Test @@ -52,12 +55,12 @@ public class HtlcTests extends Common { do { // We need to perform fresh setup for 1st test Bitcoin.resetForTesting(); - bitcoin = Bitcoin.getInstance(); + litecoin = Litecoin.getInstance(); long now = System.currentTimeMillis(); long timestampBoundary = now / 30_000L; - byte[] secret1 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + byte[] secret1 = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress); long executionPeriod1 = System.currentTimeMillis() - now; assertNotNull(secret1); @@ -65,7 +68,7 @@ public class HtlcTests extends Common { assertTrue("1st execution period should not be instant!", executionPeriod1 > 10); - byte[] secret2 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + byte[] secret2 = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress); long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1; assertNotNull(secret2); @@ -86,7 +89,7 @@ public class HtlcTests extends Common { // This actually exists on TEST3 but can take a while to fetch String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L); assertNotNull(htlcStatus); System.out.println(String.format("HTLC %s status: %s", p2shAddress, htlcStatus.name())); @@ -97,21 +100,21 @@ public class HtlcTests extends Common { do { // We need to perform fresh setup for 1st test Bitcoin.resetForTesting(); - bitcoin = Bitcoin.getInstance(); + litecoin = Litecoin.getInstance(); long now = System.currentTimeMillis(); long timestampBoundary = now / 30_000L; // Won't ever exist - String p2shAddress = bitcoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now))); + String p2shAddress = litecoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now))); - BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L); long executionPeriod1 = System.currentTimeMillis() - now; assertNotNull(htlcStatus1); assertTrue("1st execution period should not be instant!", executionPeriod1 > 10); - BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L); long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1; assertNotNull(htlcStatus2); diff --git a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java index 6236483a..66c631e5 100644 --- a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java @@ -1,114 +1,43 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Litecoin; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -public class LitecoinTests extends Common { +public class LitecoinTests extends BitcoinyTests { - private Litecoin litecoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - litecoin = Litecoin.getInstance(); + @Override + protected String getCoinName() { + return "Litecoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "LTC"; + } + + @Override + protected Bitcoiny getCoin() { + return Litecoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Litecoin.resetForTesting(); - litecoin = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + } - System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } - - @Test - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = litecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = litecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - Long balance = litecoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(litecoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = litecoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(litecoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String address = litecoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + public void testFindHtlcSecret() {} } diff --git a/src/test/java/org/qortal/test/crosschain/PirateChainTests.java b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java index 9502e45a..b212aea1 100644 --- a/src/test/java/org/qortal/test/crosschain/PirateChainTests.java +++ b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java @@ -3,57 +3,53 @@ package org.qortal.test.crosschain; import cash.z.wallet.sdk.rpc.CompactFormats.*; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; import org.qortal.transform.TransformationException; -import java.util.Arrays; import java.util.List; import static org.junit.Assert.*; import static org.qortal.crosschain.BitcoinyHTLC.Status.*; -public class PirateChainTests extends Common { +public class PirateChainTests extends BitcoinyTests { - private PirateChain pirateChain; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); - pirateChain = PirateChain.getInstance(); + @Override + protected String getCoinName() { + return "PirateChain"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "ARRR"; + } + + @Override + protected Bitcoiny getCoin() { + return PirateChain.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Litecoin.resetForTesting(); - pirateChain = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Pirate Chain median blocktime: %d", pirateChain.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return null; + } - System.out.println(String.format("Pirate Chain median blocktime: %d", pirateChain.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); + @Override + protected String getRecipient() { + return null; + } - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("1st call should take less than 5 seconds", firstPeriod < 5000L); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + public void makeGetMedianBlockTimeAssertions(long firstPeriod, long secondPeriod) { + assertTrue("1st call should take less than 5 seconds", firstPeriod < 5000L); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); } @Test @@ -62,7 +58,7 @@ public class PirateChainTests extends Common { int count = 20; long before = System.currentTimeMillis(); - List compactBlocks = pirateChain.getCompactBlocks(startHeight, count); + List compactBlocks = ((PirateChain) bitcoiny).getCompactBlocks(startHeight, count); long after = System.currentTimeMillis(); System.out.println(String.format("Retrieval took: %d ms", after-before)); @@ -82,7 +78,7 @@ public class PirateChainTests extends Common { Bytes.reverse(txBytes); String txHashBE = HashCode.fromBytes(txBytes).toString(); - byte[] rawTransaction = pirateChain.getBlockchainProvider().getRawTransaction(txHashBE); + byte[] rawTransaction = bitcoiny.getBlockchainProvider().getRawTransaction(txHashBE); assertNotNull(rawTransaction); } @@ -121,7 +117,7 @@ public class PirateChainTests extends Common { String p2shAddress = "ba6Q5HWrWtmfU2WZqQbrFdRYsafA45cUAt"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); assertEquals(FUNDED, htlcStatus); } @@ -130,7 +126,7 @@ public class PirateChainTests extends Common { String p2shAddress = "bYZrzSSgGp8aEGvihukoMGU8sXYrx19Wka"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); assertEquals(REDEEMED, htlcStatus); } @@ -139,14 +135,14 @@ public class PirateChainTests extends Common { String p2shAddress = "bE49izfVxz8odhu8c2BcUaVFUnt7NLFRgv"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); assertEquals(REFUNDED, htlcStatus); } @Test public void testGetTxidForUnspentAddress() throws ForeignBlockchainException { String p2shAddress = "ba6Q5HWrWtmfU2WZqQbrFdRYsafA45cUAt"; - String txid = PirateChainHTLC.getFundingTxid(pirateChain.getBlockchainProvider(), p2shAddress); + String txid = PirateChainHTLC.getFundingTxid(bitcoiny.getBlockchainProvider(), p2shAddress); // Reverse the byte order of the txid used by block explorers, to get to big-endian form byte[] expectedTxidLE = HashCode.fromString("fea4b0c1abcf8f0f3ddc2fa2f9438501ee102aad62a9ff18a5ce7d08774755c0").asBytes(); @@ -161,7 +157,7 @@ public class PirateChainTests extends Common { String p2shAddress = "ba6Q5HWrWtmfU2WZqQbrFdRYsafA45cUAt"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - String txid = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + String txid = PirateChainHTLC.getUnspentFundingTxid(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); // Reverse the byte order of the txid used by block explorers, to get to big-endian form byte[] expectedTxidLE = HashCode.fromString("fea4b0c1abcf8f0f3ddc2fa2f9438501ee102aad62a9ff18a5ce7d08774755c0").asBytes(); @@ -174,7 +170,7 @@ public class PirateChainTests extends Common { @Test public void testGetTxidForSpentAddress() throws ForeignBlockchainException { String p2shAddress = "bE49izfVxz8odhu8c2BcUaVFUnt7NLFRgv"; //"t3KtVxeEb8srJofo6atMEpMpEP6TjEi8VqA"; - String txid = PirateChainHTLC.getFundingTxid(pirateChain.getBlockchainProvider(), p2shAddress); + String txid = PirateChainHTLC.getFundingTxid(bitcoiny.getBlockchainProvider(), p2shAddress); // Reverse the byte order of the txid used by block explorers, to get to big-endian form byte[] expectedTxidLE = HashCode.fromString("fb386fc8eea0fbf3ea37047726b92c39441652b32d8d62a274331687f7a1eca8").asBytes(); @@ -187,7 +183,7 @@ public class PirateChainTests extends Common { @Test public void testGetTransactionsForAddress() throws ForeignBlockchainException { String p2shAddress = "bE49izfVxz8odhu8c2BcUaVFUnt7NLFRgv"; //"t3KtVxeEb8srJofo6atMEpMpEP6TjEi8VqA"; - List transactions = pirateChain.getBlockchainProvider() + List transactions = bitcoiny.getBlockchainProvider() .getAddressBitcoinyTransactions(p2shAddress, false); assertEquals(2, transactions.size()); @@ -232,66 +228,17 @@ public class PirateChainTests extends Common { @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(pirateChain, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "Needs adapting for Pirate Chain") - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = pirateChain.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = pirateChain.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } + public void testBuildSpend() {} @Test @Ignore(value = "Needs adapting for Pirate Chain") - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - Long balance = pirateChain.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(pirateChain.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = pirateChain.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(pirateChain.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } + public void testGetWalletBalance() {} @Test @Ignore(value = "Needs adapting for Pirate Chain") - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String address = pirateChain.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - -} + public void testGetUnusedReceiveAddress() {} +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/crosschain/RavencoinTests.java b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java index 866c41a2..d7581f74 100644 --- a/src/test/java/org/qortal/test/crosschain/RavencoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java @@ -1,115 +1,47 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Ravencoin; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -public class RavencoinTests extends Common { +public class RavencoinTests extends BitcoinyTests { - private Ravencoin ravencoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - ravencoin = Ravencoin.getInstance(); + @Override + protected String getCoinName() { + return "Ravencoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "RVN"; + } + + @Override + protected Bitcoiny getCoin() { + return Ravencoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Ravencoin.resetForTesting(); - ravencoin = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Ravencoin median blocktime: %d", ravencoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; + } - System.out.println(String.format("Ravencoin median blocktime: %d", ravencoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return null; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(ravencoin, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = ravencoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = ravencoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; - - Long balance = ravencoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(ravencoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = ravencoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(ravencoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; - - String address = ravencoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + public void testBuildSpend() {} } diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java index 4487e874..cc33eb43 100644 --- a/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java @@ -2,507 +2,89 @@ package org.qortal.test.crosschain.bitcoinv1; import static org.junit.Assert.*; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; import java.util.function.Function; -import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; -import org.qortal.block.Block; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crosschain.AcctMode; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; +import org.qortal.test.crosschain.ACCTTests; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -public class BitcoinACCTv1Tests extends Common { +public class BitcoinACCTv1Tests extends ACCTTests { - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] secretB = "This string is roughly 32 bytes?".getBytes(); public static final byte[] hashOfSecretB = Crypto.hash160(secretB); // 31f0dd71decf59bbc8ef0661f4030479255cfa58 public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long bitcoinAmount = 864200L; // 0.00864200 BTC - private static final Random RANDOM = new Random(); + private static final String SYMBOL = "BTC"; - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + private static final String NAME = "Bitcoin"; + + @Override + protected byte[] getPublicKey() { + return bitcoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return BitcoinACCTv1.buildQortalAT(address,publicKey, hashOfSecretB, redeemAmount, foreignAmount,tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); + @Override + protected ACCT getInstance() { + return BitcoinACCTv1.getInstance(); + } - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + } - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return BitcoinACCTv1.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); + } - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return BitcoinACCTv1.buildRedeemMessage(secretA,secretB,address); + } - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + @Override + protected byte[] getCodeBytesHash() { + return BitcoinACCTv1.CODE_BYTES_HASH; + } - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); + @Override + protected String getSymbol() { + return SYMBOL; + } - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getName() { + return NAME; } @SuppressWarnings("unused") + @Override @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = BitcoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } - } - - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Bitcoin PKH was extracted correctly - assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } - } - - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretsCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secrets to AT, from correct account - messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretsIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secrets to AT, but from wrong account - messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretsCorrectSender() throws DataException { + public void testIncorrectSecretCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount tradeAccount = createTradeAccount(repository); @@ -582,197 +164,8 @@ public class BitcoinACCTv1Tests extends Common { } } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretsCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secrets to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA, secretB); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, BitcoinACCTv1.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-BTC cross-chain trade"; - String description = String.format("Qortal-Bitcoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-BTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout * 3 / 4 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tHASH160 of secret-B: %s,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected bitcoin: %s BTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - + @Override + protected void describeRefundAt(CrossChainTradeData tradeData, Function epochMilliFormatter) { if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { System.out.println(String.format("\trefund height: block %d,\n" + "\tHASH160 of secret-A: %s,\n" @@ -786,10 +179,4 @@ public class BitcoinACCTv1Tests extends Common { tradeData.qortalPartnerAddress)); } } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java index 01345727..5e0048bf 100644 --- a/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.bitcoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.BitcoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class BitcoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class BitcoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long bitcoinAmount = 864200L; // 0.00864200 BTC + private static final String SYMBOL = "BTC"; + private static final String NAME = "Bitcoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return bitcoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = BitcoinACCTv3.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, redeemAmount, bitcoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return BitcoinACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return BitcoinACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = BitcoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return BitcoinACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Bitcoin PKH was extracted correctly - assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return BitcoinACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return BitcoinACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = BitcoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = BitcoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = BitcoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, BitcoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = BitcoinACCTv3.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, redeemAmount, bitcoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-BTC cross-chain trade"; - String description = String.format("Qortal-Bitcoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-BTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected Bitcoin: %s BTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java index d13aba4c..01ead678 100644 --- a/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.digibytev3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.DigibyteACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class DigibyteACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class DigibyteACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] digibytePublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long digibyteAmount = 864200L; // 0.00864200 DGB + private static final String SYMBOL = "DGB"; + private static final String NAME = "DigiByte"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return digibytePublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = DigibyteACCTv3.buildQortalAT(tradeAccount.getAddress(), digibytePublicKeyHash, redeemAmount, digibyteAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return DigibyteACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return DigibyteACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = DigibyteACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return DigibyteACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's digibyte PKH was extracted correctly - assertTrue(Arrays.equals(digibytePublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return DigibyteACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return DigibyteACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = DigibyteACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = DigibyteACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = DigibyteACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, DigibyteACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = DigibyteACCTv3.buildQortalAT(tradeAddress, digibytePublicKeyHash, redeemAmount, digibyteAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-DGB cross-chain trade"; - String description = String.format("Qortal-Digibyte cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-DGB ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected digibyte: %s DGB,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tDigibyte P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java index 7056e433..551173f7 100644 --- a/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.dogecoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.DogecoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class DogecoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class DogecoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] dogecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long dogecoinAmount = 864200L; // 0.00864200 DOGE + private static final String SYMBOL = "DOGE"; + private static final String NAME = "Dogecoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return dogecoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = DogecoinACCTv3.buildQortalAT(tradeAccount.getAddress(), dogecoinPublicKeyHash, redeemAmount, dogecoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return DogecoinACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return DogecoinACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = DogecoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return DogecoinACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's dogecoin PKH was extracted correctly - assertTrue(Arrays.equals(dogecoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return DogecoinACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return DogecoinACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = DogecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = DogecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = DogecoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, DogecoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = DogecoinACCTv3.buildQortalAT(tradeAddress, dogecoinPublicKeyHash, redeemAmount, dogecoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-DOGE cross-chain trade"; - String description = String.format("Qortal-Dogecoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-DOGE ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected dogecoin: %s DOGE,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tDogecoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java index 609ff5f3..91a450d0 100644 --- a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java @@ -1,770 +1,60 @@ package org.qortal.test.crosschain.litecoinv1; -import static org.junit.Assert.*; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; - -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.LitecoinACCTv1; -import org.qortal.crosschain.AcctMode; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -public class LitecoinACCTv1Tests extends Common { +public class LitecoinACCTv1Tests extends ACCTTests { - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long litecoinAmount = 864200L; // 0.00864200 LTC + private static final String SYMBOL = "LTC"; - private static final Random RANDOM = new Random(); + private static final String NAME = "Litecoin"; - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return litecoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return LitecoinACCTv1.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return LitecoinACCTv1.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = LitecoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return LitecoinACCTv1.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Litecoin PKH was extracted correctly - assertTrue(Arrays.equals(litecoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return LitecoinACCTv1.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return LitecoinACCTv1.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = LitecoinACCTv1.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, LitecoinACCTv1.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-LTC cross-chain trade"; - String description = String.format("Qortal-Litecoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-LTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected Litecoin: %s LTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tLitecoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java index 009af5ea..a1a0bfcc 100644 --- a/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.litecoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; -import org.qortal.crosschain.LitecoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class LitecoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class LitecoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long litecoinAmount = 864200L; // 0.00864200 LTC + private static final String SYMBOL = "LTC"; + private static final String NAME = "Litecoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return litecoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = LitecoinACCTv3.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return LitecoinACCTv1.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return LitecoinACCTv1.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = LitecoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return LitecoinACCTv1.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Litecoin PKH was extracted correctly - assertTrue(Arrays.equals(litecoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return LitecoinACCTv1.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return LitecoinACCTv1.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = LitecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = LitecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = LitecoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, LitecoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = LitecoinACCTv3.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-LTC cross-chain trade"; - String description = String.format("Qortal-Litecoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-LTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected Litecoin: %s LTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tLitecoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java index f9ac9de1..18099872 100644 --- a/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java @@ -1,771 +1,58 @@ package org.qortal.test.crosschain.piratechainv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.PirateChainACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class PirateChainACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class PirateChainACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] pirateChainPublicKey = HashCode.fromString("aabb00bb11bb22bb33bb44bb55bb66bb77bb88bb99cc00cc11cc22cc33cc44cc55").asBytes(); // 33 bytes - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long arrrAmount = 864200L; // 0.00864200 ARRR + private static final String SYMBOL = "ARRR"; + private static final String NAME = "Pirate Chain"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return pirateChainPublicKey; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = PirateChainACCTv3.buildQortalAT(tradeAccount.getAddress(), pirateChainPublicKey, redeemAmount, arrrAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return PirateChainACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return PirateChainACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = PirateChainACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return PirateChainACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - System.out.println(String.format("pirateChainPublicKey: %s", HashCode.fromBytes(pirateChainPublicKey))); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Litecoin PKH was extracted correctly - assertTrue(Arrays.equals(pirateChainPublicKey, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return PirateChainACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return PirateChainACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = PirateChainACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = PirateChainACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = PirateChainACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, PirateChainACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = PirateChainACCTv3.buildQortalAT(tradeAddress, pirateChainPublicKey, redeemAmount, arrrAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-ARRR cross-chain trade"; - String description = String.format("Qortal-PirateChain cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-ARRR ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected ARRR: %s ARRR,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tPirate Chain P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java index 012d5f5d..1af0f7d6 100644 --- a/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.ravencoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.RavencoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class RavencoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class RavencoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] ravencoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long ravencoinAmount = 864200L; // 0.00864200 RVN + private static final String SYMBOL = "RVN"; + private static final String NAME = "Ravencoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return ravencoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = RavencoinACCTv3.buildQortalAT(tradeAccount.getAddress(), ravencoinPublicKeyHash, redeemAmount, ravencoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return RavencoinACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return RavencoinACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = RavencoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return RavencoinACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's ravencoin PKH was extracted correctly - assertTrue(Arrays.equals(ravencoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return RavencoinACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return RavencoinACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = RavencoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = RavencoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = RavencoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, RavencoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = RavencoinACCTv3.buildQortalAT(tradeAddress, ravencoinPublicKeyHash, redeemAmount, ravencoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-RVN cross-chain trade"; - String description = String.format("Qortal-Ravencoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-RVN ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected ravencoin: %s RVN,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tRavencoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/group/AdminTests.java b/src/test/java/org/qortal/test/group/AdminTests.java index a39b23d7..8cf83c29 100644 --- a/src/test/java/org/qortal/test/group/AdminTests.java +++ b/src/test/java/org/qortal/test/group/AdminTests.java @@ -135,7 +135,8 @@ public class AdminTests extends Common { assertNotSame(ValidationResult.OK, result); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + int timeToLive = 0; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -158,7 +159,7 @@ public class AdminTests extends Common { assertTrue(isMember(repository, bob.getAddress(), groupId)); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -205,6 +206,144 @@ public class AdminTests extends Common { } } + @Test + public void testGroupBanMemberWithExpiry() throws DataException, InterruptedException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Create group + int groupId = createGroup(repository, alice, "open-group", true); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to cancel non-existent Bob ban + ValidationResult result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Attempt to ban Bob for 2 seconds + int timeToLive = 2; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Wait for 2 seconds to pass + Thread.sleep(2000L); + + // Bob attempts to rejoin again + result = joinGroup(repository, bob, groupId); + // Should be OK, as the ban has expired + assertSame(ValidationResult.OK, result); + + // Confirm Bob is now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob to join + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + + // Attempt to ban Bob for 2 seconds + result = groupBan(repository, alice, groupId, bob.getAddress(), 2); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Wait for 2 seconds to pass + Thread.sleep(2000L); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK, as ban has already expired + assertNotSame(ValidationResult.OK, result); + + // Confirm Bob still not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK, as no longer banned + assertSame(ValidationResult.OK, result); + + // Confirm Bob is now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + + // Attempt to ban Bob for 10 seconds + result = groupBan(repository, alice, groupId, bob.getAddress(), 10); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK, as ban still exists + assertNotSame(ValidationResult.OK, result); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK, as ban still exists + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK, as no longer banned + assertEquals(ValidationResult.OK, result); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed join-group transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Orphan last block (Cancel Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed cancel-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + @Test public void testGroupBanAdmin() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -226,7 +365,8 @@ public class AdminTests extends Common { assertTrue(isAdmin(repository, bob.getAddress(), groupId)); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + int timeToLive = 0; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -272,12 +412,12 @@ public class AdminTests extends Common { assertTrue(isAdmin(repository, bob.getAddress(), groupId)); // Have Alice (owner) try to ban herself! - result = groupBan(repository, alice, groupId, alice.getAddress()); + result = groupBan(repository, alice, groupId, alice.getAddress(), timeToLive); // Should NOT be OK assertNotSame(ValidationResult.OK, result); // Have Bob try to ban Alice (owner) - result = groupBan(repository, bob, groupId, alice.getAddress()); + result = groupBan(repository, bob, groupId, alice.getAddress(), timeToLive); // Should NOT be OK assertNotSame(ValidationResult.OK, result); } @@ -316,8 +456,8 @@ public class AdminTests extends Common { return result; } - private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { - GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", 0); + private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member, int timeToLive) throws DataException { + GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", timeToLive); ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); if (result == ValidationResult.OK) diff --git a/src/test/java/org/qortal/test/group/DevGroupAdminTests.java b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java new file mode 100644 index 00000000..131359c6 --- /dev/null +++ b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java @@ -0,0 +1,388 @@ +package org.qortal.test.group; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.*; +import org.qortal.group.Group; +import org.qortal.group.Group.ApprovalThreshold; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.GroupUtils; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.utils.Base58; + +import static org.junit.Assert.*; + +/** + * Dev group admin tests + * + * The dev group (ID 1) is owned by the null account with public key 11111111111111111111111111111111 + * To regain access to otherwise blocked owner-based rules, it has different validation logic + * which applies to groups with this same null owner. + * + * The main difference is that approval is required for certain transaction types relating to + * null-owned groups. This allows existing admins to approve updates to the group (using group's + * approval threshold) instead of these actions being performed by the owner. + * + * Since these apply to all null-owned groups, this allows anyone to update their group to + * the null owner if they want to take advantage of this decentralized approval system. + * + * Currently, the affected transaction types are: + * - AddGroupAdminTransaction + * - RemoveGroupAdminTransaction + * + * This same approach could ultimately be applied to other group transactions too. + */ +public class DevGroupAdminTests extends Common { + + private static final int DEV_GROUP_ID = 1; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + @Test + public void testGroupKickMember() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + ValidationResult result = groupKick(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + joinGroup(repository, bob, groupId); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + result = groupKick(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + + @Test + public void testGroupKickAdmin() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + joinGroup(repository, bob, groupId); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Promote Bob to admin + TransactionData addGroupAdminTransactionData = addGroupAdmin(repository, alice, groupId, bob.getAddress()); + + // Confirm transaction needs approval, and hasn't been approved + Transaction.ApprovalStatus approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); + + // Have Alice approve Bob's approval-needed transaction + GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + + // Mint a block so that the transaction becomes approved + BlockUtils.mintBlock(repository); + + // Confirm transaction is approved + approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.APPROVED, approvalStatus); + + // Confirm Bob is now admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + ValidationResult result = groupKick(repository, alice, groupId, bob.getAddress()); + // Shouldn't be allowed + assertEquals(ValidationResult.INVALID_GROUP_OWNER, result); + + // Confirm Bob is still a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Confirm Bob still an admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob no longer an admin (ADD_GROUP_ADMIN no longer approved) + assertFalse(isAdmin(repository, bob.getAddress(), groupId)); + + // Have Alice try to kick herself! + result = groupKick(repository, alice, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Have Bob try to kick Alice + result = groupKick(repository, bob, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + } + } + + @Test + public void testGroupBanMember() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to cancel non-existent Bob ban + ValidationResult result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed join-group transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Orphan last block (Cancel Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed cancel-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + + @Test + public void testGroupBanAdmin() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + ValidationResult result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Promote Bob to admin + TransactionData addGroupAdminTransactionData = addGroupAdmin(repository, alice, groupId, bob.getAddress()); + + // Confirm transaction needs approval, and hasn't been approved + Transaction.ApprovalStatus approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); + + // Have Alice approve Bob's approval-needed transaction + GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + + // Mint a block so that the transaction becomes approved + BlockUtils.mintBlock(repository); + + // Confirm transaction is approved + approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.APPROVED, approvalStatus); + + // Confirm Bob is now admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // .. but we can't, because Bob is an admin and the group has no owner + assertEquals(ValidationResult.INVALID_GROUP_OWNER, result); + + // Confirm Bob still a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // ... and still an admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Have Alice try to ban herself! + result = groupBan(repository, alice, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Have Bob try to ban Alice + result = groupBan(repository, bob, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + } + } + + + private ValidationResult joinGroup(Repository repository, PrivateKeyAccount joiner, int groupId) throws DataException { + JoinGroupTransactionData transactionData = new JoinGroupTransactionData(TestTransaction.generateBase(joiner), groupId); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, joiner); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private void groupInvite(Repository repository, PrivateKeyAccount admin, int groupId, String invitee, int timeToLive) throws DataException { + GroupInviteTransactionData transactionData = new GroupInviteTransactionData(TestTransaction.generateBase(admin), groupId, invitee, timeToLive); + TransactionUtils.signAndMint(repository, transactionData, admin); + } + + private ValidationResult groupKick(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + GroupKickTransactionData transactionData = new GroupKickTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing"); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", 0); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private ValidationResult cancelGroupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + CancelGroupBanTransactionData transactionData = new CancelGroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private TransactionData addGroupAdmin(Repository repository, PrivateKeyAccount owner, int groupId, String member) throws DataException { + AddGroupAdminTransactionData transactionData = new AddGroupAdminTransactionData(TestTransaction.generateBase(owner), groupId, member); + transactionData.setTxGroupId(groupId); + TransactionUtils.signAndMint(repository, transactionData, owner); + return transactionData; + } + + private boolean isMember(Repository repository, String address, int groupId) throws DataException { + return repository.getGroupRepository().memberExists(groupId, address); + } + + private boolean isAdmin(Repository repository, String address, int groupId) throws DataException { + return repository.getGroupRepository().adminExists(groupId, address); + } + +} diff --git a/src/test/java/org/qortal/test/naming/BuySellTests.java b/src/test/java/org/qortal/test/naming/BuySellTests.java index faed3d72..f0e97b94 100644 --- a/src/test/java/org/qortal/test/naming/BuySellTests.java +++ b/src/test/java/org/qortal/test/naming/BuySellTests.java @@ -44,7 +44,7 @@ public class BuySellTests extends Common { bob = Common.getTestAccount(repository, "bob"); name = "test name" + " " + random.nextInt(1_000_000); - price = random.nextInt(1000) * Amounts.MULTIPLIER; + price = (random.nextInt(1000) + 1) * Amounts.MULTIPLIER; } @After @@ -165,6 +165,52 @@ public class BuySellTests extends Common { assertEquals("price incorrect", price, nameData.getSalePrice()); } + @Test + public void testCancelSellNameAndRelist() throws DataException { + // Register-name and sell-name + testSellName(); + + // Cancel Sell-name + CancelSellNameTransactionData transactionData = new CancelSellNameTransactionData(TestTransaction.generateBase(alice), name); + TransactionUtils.signAndMint(repository, transactionData, alice); + + NameData nameData; + + // Check name is no longer for sale + nameData = repository.getNameRepository().fromName(name); + assertFalse(nameData.isForSale()); + assertNull(nameData.getSalePrice()); + + // Re-sell-name + Long newPrice = random.nextInt(1000) * Amounts.MULTIPLIER; + SellNameTransactionData sellNameTransactionData = new SellNameTransactionData(TestTransaction.generateBase(alice), name, newPrice); + TransactionUtils.signAndMint(repository, sellNameTransactionData, alice); + + // Check name is for sale + nameData = repository.getNameRepository().fromName(name); + assertTrue(nameData.isForSale()); + assertEquals("price incorrect", newPrice, nameData.getSalePrice()); + + // Orphan sell-name + BlockUtils.orphanLastBlock(repository); + + // Check name no longer for sale + nameData = repository.getNameRepository().fromName(name); + assertFalse(nameData.isForSale()); + assertNull(nameData.getSalePrice()); + + // Orphan cancel-sell-name + BlockUtils.orphanLastBlock(repository); + + // Check name is for sale (at original price) + nameData = repository.getNameRepository().fromName(name); + assertTrue(nameData.isForSale()); + assertEquals("price incorrect", price, nameData.getSalePrice()); + + // Orphan sell-name and register-name + BlockUtils.orphanBlocks(repository, 2); + } + @Test public void testBuyName() throws DataException { // Register-name and sell-name diff --git a/src/test/java/org/qortal/test/naming/IntegrityTests.java b/src/test/java/org/qortal/test/naming/IntegrityTests.java index d52d4983..767ea388 100644 --- a/src/test/java/org/qortal/test/naming/IntegrityTests.java +++ b/src/test/java/org/qortal/test/naming/IntegrityTests.java @@ -128,7 +128,7 @@ public class IntegrityTests extends Common { // Run the database integrity check for the initial name, to ensure it doesn't get into a loop NamesDatabaseIntegrityCheck integrityCheck = new NamesDatabaseIntegrityCheck(); - assertEquals(2, integrityCheck.rebuildName(initialName, repository)); + assertEquals(4, integrityCheck.rebuildName(initialName, repository)); // 4 transactions total // Ensure the new name still exists and the data is still correct assertTrue(repository.getNameRepository().nameExists(initialName)); diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 2bcd098d..401b03b9 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -20,6 +20,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.test.common.*; import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.PaymentTransaction; import org.qortal.transaction.RegisterNameTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -329,15 +330,19 @@ public class MiscTests extends Common { public void testRegisterNameFeeIncrease() throws Exception { try (final Repository repository = RepositoryManager.getRepository()) { - // Set nameRegistrationUnitFeeTimestamp to a time far in the future + // Add original fee to nameRegistrationUnitFees + UnitFeesByTimestamp originalFee = new UnitFeesByTimestamp(); + originalFee.timestamp = 0L; + originalFee.fee = new AmountTypeAdapter().unmarshal("0.1"); + + // Add a time far in the future to nameRegistrationUnitFees UnitFeesByTimestamp futureFeeIncrease = new UnitFeesByTimestamp(); futureFeeIncrease.timestamp = 9999999999999L; // 20 Nov 2286 futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("5"); - FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(futureFeeIncrease), true); + FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(originalFee, futureFeeIncrease), true); assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); // Validate unit fees pre and post timestamp - assertEquals(10000000, BlockChain.getInstance().getUnitFee()); // 0.1 QORT assertEquals(10000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp - 1)); // 0.1 QORT assertEquals(500000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); // 5 QORT @@ -362,7 +367,7 @@ public class MiscTests extends Common { futureFeeIncrease.timestamp = now + (60 * 60 * 1000L); // 1 hour in the future futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("10"); - FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(pastFeeIncrease, futureFeeIncrease), true); + FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(originalFee, pastFeeIncrease, futureFeeIncrease), true); assertEquals(pastFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(pastFeeIncrease.timestamp)); assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); @@ -387,4 +392,124 @@ public class MiscTests extends Common { } } + // test reading the name registration fee schedule from blockchain.json / test-chain-v2.json + @Test + public void testRegisterNameFeeScheduleInTestchainData() throws Exception { + try (final Repository repository = RepositoryManager.getRepository()) { + + final long expectedFutureFeeIncreaseTimestamp = 9999999999999L; // 20 Nov 2286, as per test-chain-v2.json + final long expectedFutureFeeIncreaseValue = new AmountTypeAdapter().unmarshal("5"); + + assertEquals(expectedFutureFeeIncreaseValue, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); + + // Validate unit fees pre and post timestamp + assertEquals(10000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp - 1)); // 0.1 QORT + assertEquals(500000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); // 5 QORT + + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(10000000L, transactionData.getFee().longValue()); + TransactionUtils.signAndMint(repository, transactionData, alice); + } + } + + + + // test general unit fee increase + @Test + public void testUnitFeeIncrease() throws Exception { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Add original fee to unitFees + UnitFeesByTimestamp originalFee = new UnitFeesByTimestamp(); + originalFee.timestamp = 0L; + originalFee.fee = new AmountTypeAdapter().unmarshal("0.1"); + + // Add a time far in the future to unitFees + UnitFeesByTimestamp futureFeeIncrease = new UnitFeesByTimestamp(); + futureFeeIncrease.timestamp = 9999999999999L; // 20 Nov 2286 + futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("1"); + FieldUtils.writeField(BlockChain.getInstance(), "unitFees", Arrays.asList(originalFee, futureFeeIncrease), true); + assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); + + // Validate unit fees pre and post timestamp + assertEquals(10000000, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp - 1)); // 0.1 QORT + assertEquals(100000000, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); // 1 QORT + + // Payment + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloe = Common.getTestAccount(repository, "chloe"); + + PaymentTransactionData transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 100000); + transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(10000000L, transactionData.getFee().longValue()); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Set fee increase to a time in the past + Long now = NTP.getTime(); + UnitFeesByTimestamp pastFeeIncrease = new UnitFeesByTimestamp(); + pastFeeIncrease.timestamp = now - 1000L; // 1 second ago + pastFeeIncrease.fee = new AmountTypeAdapter().unmarshal("3"); + + // Set another increase in the future + futureFeeIncrease = new UnitFeesByTimestamp(); + futureFeeIncrease.timestamp = now + (60 * 60 * 1000L); // 1 hour in the future + futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("10"); + + FieldUtils.writeField(BlockChain.getInstance(), "unitFees", Arrays.asList(originalFee, pastFeeIncrease, futureFeeIncrease), true); + assertEquals(originalFee.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(originalFee.timestamp)); + assertEquals(pastFeeIncrease.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(pastFeeIncrease.timestamp)); + assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); + + // Send another payment transaction + // Fee should be determined automatically + transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 50000); + assertEquals(300000000L, transactionData.getFee().longValue()); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + ValidationResult result = transaction.importAsUnconfirmed(); + assertEquals("Transaction should be valid", ValidationResult.OK, result); + + // Now try fetching and setting fee manually + transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), chloe.getAddress(), 50000); + transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(300000000L, transactionData.getFee().longValue()); + transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + result = transaction.importAsUnconfirmed(); + assertEquals("Transaction should be valid", ValidationResult.OK, result); + } + } + + // test reading the fee schedule from blockchain.json / test-chain-v2.json + @Test + public void testFeeScheduleInTestchainData() throws Exception { + try (final Repository repository = RepositoryManager.getRepository()) { + + final long expectedFutureFeeIncreaseTimestamp = 9999999999999L; // 20 Nov 2286, as per test-chain-v2.json + final long expectedFutureFeeIncreaseValue = new AmountTypeAdapter().unmarshal("1"); + + assertEquals(expectedFutureFeeIncreaseValue, BlockChain.getInstance().getUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); + + // Validate unit fees pre and post timestamp + assertEquals(10000000, BlockChain.getInstance().getUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp - 1)); // 0.1 QORT + assertEquals(100000000, BlockChain.getInstance().getUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); // 1 QORT + + // Payment + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + PaymentTransactionData transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 100000); + transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(10000000L, transactionData.getFee().longValue()); + TransactionUtils.signAndMint(repository, transactionData, alice); + } + } + } diff --git a/src/test/java/org/qortal/test/naming/UpdateTests.java b/src/test/java/org/qortal/test/naming/UpdateTests.java index 24af5317..54227e94 100644 --- a/src/test/java/org/qortal/test/naming/UpdateTests.java +++ b/src/test/java/org/qortal/test/naming/UpdateTests.java @@ -219,6 +219,65 @@ public class UpdateTests extends Common { } } + // Test that multiple UPDATE_NAME transactions work as expected, when using a matching name and newName string + @Test + public void testDoubleUpdateNameWithMatchingNewName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "name"; + String reducedName = "name"; + String data = "{\"age\":30}"; + + TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + initialTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(initialTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, initialTransactionData, alice); + + // Check name exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Update name + TransactionData middleTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), name, name, data); + TransactionUtils.signAndMint(repository, middleTransactionData, alice); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Update name again + TransactionData newestTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), name, name, data); + TransactionUtils.signAndMint(repository, newestTransactionData, alice); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Check updated timestamp is correct + assertEquals((Long) newestTransactionData.getTimestamp(), repository.getNameRepository().fromName(name).getUpdated()); + + // orphan and recheck + BlockUtils.orphanLastBlock(repository); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Check updated timestamp is correct + assertEquals((Long) middleTransactionData.getTimestamp(), repository.getNameRepository().fromName(name).getUpdated()); + + // orphan and recheck + BlockUtils.orphanLastBlock(repository); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Check updated timestamp is empty + assertNull(repository.getNameRepository().fromName(name).getUpdated()); + } + } + // Test that reverting using previous UPDATE_NAME works as expected @Test public void testIntermediateUpdateName() throws DataException { diff --git a/src/test/java/org/qortal/test/network/OnlineAccountsTests.java b/src/test/java/org/qortal/test/network/OnlineAccountsTests.java index c9e646f1..c8220d66 100644 --- a/src/test/java/org/qortal/test/network/OnlineAccountsTests.java +++ b/src/test/java/org/qortal/test/network/OnlineAccountsTests.java @@ -51,89 +51,6 @@ public class OnlineAccountsTests extends Common { } - @Test - public void testGetOnlineAccountsV2() throws MessageException { - List onlineAccountsOut = generateOnlineAccounts(false); - - Message messageOut = new GetOnlineAccountsV2Message(onlineAccountsOut); - - byte[] messageBytes = messageOut.toBytes(); - ByteBuffer byteBuffer = ByteBuffer.wrap(messageBytes); - - GetOnlineAccountsV2Message messageIn = (GetOnlineAccountsV2Message) Message.fromByteBuffer(byteBuffer); - - List onlineAccountsIn = messageIn.getOnlineAccounts(); - - assertEquals("size mismatch", onlineAccountsOut.size(), onlineAccountsIn.size()); - assertTrue("accounts mismatch", onlineAccountsIn.containsAll(onlineAccountsOut)); - - Message oldMessageOut = new GetOnlineAccountsMessage(onlineAccountsOut); - byte[] oldMessageBytes = oldMessageOut.toBytes(); - - long numTimestamps = onlineAccountsOut.stream().mapToLong(OnlineAccountData::getTimestamp).sorted().distinct().count(); - - System.out.println(String.format("For %d accounts split across %d timestamp%s: old size %d vs new size %d", - onlineAccountsOut.size(), - numTimestamps, - numTimestamps != 1 ? "s" : "", - oldMessageBytes.length, - messageBytes.length)); - } - - @Test - public void testOnlineAccountsV2() throws MessageException { - List onlineAccountsOut = generateOnlineAccounts(true); - - Message messageOut = new OnlineAccountsV2Message(onlineAccountsOut); - - byte[] messageBytes = messageOut.toBytes(); - ByteBuffer byteBuffer = ByteBuffer.wrap(messageBytes); - - OnlineAccountsV2Message messageIn = (OnlineAccountsV2Message) Message.fromByteBuffer(byteBuffer); - - List onlineAccountsIn = messageIn.getOnlineAccounts(); - - assertEquals("size mismatch", onlineAccountsOut.size(), onlineAccountsIn.size()); - assertTrue("accounts mismatch", onlineAccountsIn.containsAll(onlineAccountsOut)); - - Message oldMessageOut = new OnlineAccountsMessage(onlineAccountsOut); - byte[] oldMessageBytes = oldMessageOut.toBytes(); - - long numTimestamps = onlineAccountsOut.stream().mapToLong(OnlineAccountData::getTimestamp).sorted().distinct().count(); - - System.out.println(String.format("For %d accounts split across %d timestamp%s: old size %d vs new size %d", - onlineAccountsOut.size(), - numTimestamps, - numTimestamps != 1 ? "s" : "", - oldMessageBytes.length, - messageBytes.length)); - } - - private List generateOnlineAccounts(boolean withSignatures) { - List onlineAccounts = new ArrayList<>(); - - int numTimestamps = RANDOM.nextInt(2) + 1; // 1 or 2 - - for (int t = 0; t < numTimestamps; ++t) { - int numAccounts = RANDOM.nextInt(3000); - - for (int a = 0; a < numAccounts; ++a) { - byte[] sig = null; - if (withSignatures) { - sig = new byte[Transformer.SIGNATURE_LENGTH]; - RANDOM.nextBytes(sig); - } - - byte[] pubkey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - RANDOM.nextBytes(pubkey); - - onlineAccounts.add(new OnlineAccountData(t << 32, sig, pubkey)); - } - } - - return onlineAccounts; - } - @Test public void testOnlineAccountsModulusV1() throws IllegalAccessException, DataException { try (final Repository repository = RepositoryManager.getRepository()) { diff --git a/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java b/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java index 6136c1e1..2c3c01ca 100644 --- a/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java +++ b/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java @@ -26,41 +26,6 @@ public class OnlineAccountsV3Tests { Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); } - @Ignore("For informational use") - @Test - public void compareV2ToV3() throws MessageException { - List onlineAccounts = generateOnlineAccounts(false); - - // How many of each timestamp and leading byte (of public key) - Map> hashesByTimestampThenByte = convertToHashMaps(onlineAccounts); - - byte[] v3DataBytes = new GetOnlineAccountsV3Message(hashesByTimestampThenByte).toBytes(); - int v3ByteSize = v3DataBytes.length; - - byte[] v2DataBytes = new GetOnlineAccountsV2Message(onlineAccounts).toBytes(); - int v2ByteSize = v2DataBytes.length; - - int numTimestamps = hashesByTimestampThenByte.size(); - System.out.printf("For %d accounts split across %d timestamp%s: V2 size %d vs V3 size %d%n", - onlineAccounts.size(), - numTimestamps, - numTimestamps != 1 ? "s" : "", - v2ByteSize, - v3ByteSize - ); - - for (var outerMapEntry : hashesByTimestampThenByte.entrySet()) { - long timestamp = outerMapEntry.getKey(); - - var innerMap = outerMapEntry.getValue(); - - System.out.printf("For timestamp %d: %d / 256 slots used.%n", - timestamp, - innerMap.size() - ); - } - } - private Map> convertToHashMaps(List onlineAccounts) { // How many of each timestamp and leading byte (of public key) Map> hashesByTimestampThenByte = new HashMap<>(); @@ -200,7 +165,9 @@ public class OnlineAccountsV3Tests { byte[] pubkey = new byte[Transformer.PUBLIC_KEY_LENGTH]; RANDOM.nextBytes(pubkey); - onlineAccounts.add(new OnlineAccountData(timestamp, sig, pubkey)); + Integer nonce = RANDOM.nextInt(); + + onlineAccounts.add(new OnlineAccountData(timestamp, sig, pubkey, nonce)); } } diff --git a/src/test/java/org/qortal/test/at/AtSerializationTests.java b/src/test/java/org/qortal/test/serialization/AtSerializationTests.java similarity index 99% rename from src/test/java/org/qortal/test/at/AtSerializationTests.java rename to src/test/java/org/qortal/test/serialization/AtSerializationTests.java index 3953bcdf..ea8d6bcd 100644 --- a/src/test/java/org/qortal/test/at/AtSerializationTests.java +++ b/src/test/java/org/qortal/test/serialization/AtSerializationTests.java @@ -1,4 +1,4 @@ -package org.qortal.test.at; +package org.qortal.test.serialization; import com.google.common.hash.HashCode; import org.junit.After; diff --git a/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java b/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java new file mode 100644 index 00000000..983896db --- /dev/null +++ b/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java @@ -0,0 +1,102 @@ +package org.qortal.test.serialization; + +import com.google.common.hash.HashCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.Common; +import org.qortal.test.common.transaction.ChatTestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; + +import static org.junit.Assert.*; + +public class ChatSerializationTests { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + + @Test + public void testChatSerializationWithChatReference() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build MESSAGE-type AT transaction with chatReference + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ChatTransactionData transactionData = (ChatTransactionData) ChatTestTransaction.randomTransaction(repository, signingAccount, true); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + assertNotNull(transactionData.getChatReference()); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized CHAT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized CHAT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + + // Deserialized chat reference must match initial chat reference + ChatTransactionData deserializedChatTransactionData = (ChatTransactionData) deserializedTransactionData; + assertNotNull(deserializedChatTransactionData.getChatReference()); + assertArrayEquals(deserializedChatTransactionData.getChatReference(), transactionData.getChatReference()); + } + } + + @Test + public void testChatSerializationWithoutChatReference() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build MESSAGE-type AT transaction without chatReference + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ChatTransactionData transactionData = (ChatTransactionData) ChatTestTransaction.randomTransaction(repository, signingAccount, true); + transactionData.setChatReference(null); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + assertNull(transactionData.getChatReference()); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized CHAT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized CHAT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + + // Deserialized chat reference must match initial chat reference + ChatTransactionData deserializedChatTransactionData = (ChatTransactionData) deserializedTransactionData; + assertNull(deserializedChatTransactionData.getChatReference()); + assertArrayEquals(deserializedChatTransactionData.getChatReference(), transactionData.getChatReference()); + } + } + +} diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/serialization/SerializationTests.java similarity index 98% rename from src/test/java/org/qortal/test/SerializationTests.java rename to src/test/java/org/qortal/test/serialization/SerializationTests.java index d9fe978c..e9767909 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/serialization/SerializationTests.java @@ -1,4 +1,4 @@ -package org.qortal.test; +package org.qortal.test.serialization; import org.junit.Ignore; import org.junit.Test; @@ -47,7 +47,6 @@ public class SerializationTests extends Common { switch (txType) { case GENESIS: case ACCOUNT_FLAGS: - case CHAT: case PUBLICIZE: case AIRDROP: case ENABLE_FORGING: @@ -60,6 +59,7 @@ public class SerializationTests extends Common { TransactionData transactionData = TransactionUtils.randomTransaction(repository, signingAccount, txType, true); Transaction transaction = Transaction.fromData(repository, transactionData); transaction.sign(signingAccount); + transaction.importAsUnconfirmed(); final int claimedLength = TransactionTransformer.getDataLength(transactionData); byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 37224684..7059e035 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -14,6 +17,8 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -69,7 +74,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index 7ea0b86d..1016bc17 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -18,6 +21,8 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -72,7 +77,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 0 + "disableReferenceTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 85a50f83..5f29bc97 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index ebc3ccfa..86f2def1 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index cc91f993..b2da6489 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 085d1dbf..2933a63d 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 75858057..40e40673 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -74,7 +79,13 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "aggregateSignatureTimestamp": 0 + "aggregateSignatureTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 0706c5bb..8ceafe63 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index b3644d6b..68a79ed4 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1c68dda4..cc02a73e 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -19,6 +22,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 10d2aab3..5c508188 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, @@ -18,6 +21,8 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +78,13 @@ "newConsensusTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json new file mode 100644 index 00000000..244d2491 --- /dev/null +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -0,0 +1,121 @@ +{ + "isTestChain": true, + "blockTimestampMargin": 500, + "transactionExpiryPeriod": 86400000, + "maxBlockSize": 2097152, + "maxBytesPerUnitFee": 0, + "unitFees": [ + { "timestamp": 0, "fee": "0.00000001" } + ], + "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.00000001" }, + { "timestamp": 1645372800000, "fee": "5" } + ], + "requireGroupForApproval": false, + "minAccountLevelToRewardShare": 5, + "maxRewardSharesPerFounderMintingAccount": 20, + "maxRewardSharesByTimestamp": [ + { "timestamp": 0, "maxShares": 20 }, + { "timestamp": 9999999999999, "maxShares": 3 } + ], + "founderEffectiveMintingLevel": 10, + "onlineAccountSignaturesMinLifetime": 3600000, + "onlineAccountSignaturesMaxLifetime": 86400000, + "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, + "rewardsByHeight": [ + { "height": 1, "reward": 100 }, + { "height": 11, "reward": 10 }, + { "height": 21, "reward": 1 } + ], + "sharesByLevelV1": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.05 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.10 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.15 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.20 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.25 } + ], + "sharesByLevelV2": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.06 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.13 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.19 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.26 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.32 } + ], + "qoraHoldersShareByHeight": [ + { "height": 1, "share": 0.20 }, + { "height": 1000000, "share": 0.01 } + ], + "qoraPerQortReward": 250, + "minAccountsToActivateShareBin": 0, + "shareBinActivationMinLevel": 7, + "blocksNeededByLevel": [ 5, 20, 30, 40, 50, 60, 18, 80, 90, 100 ], + "blockTimingsByHeight": [ + { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } + ], + "ciyamAtSettings": { + "feePerStep": "0.0001", + "maxStepsPerRound": 500, + "stepsPerFunctionCall": 10, + "minutesPerBlock": 1 + }, + "featureTriggers": { + "messageHeight": 0, + "atHeight": 0, + "assetsTimestamp": 0, + "votingTimestamp": 0, + "arbitraryTimestamp": 0, + "powfixTimestamp": 0, + "qortalTimestamp": 0, + "newAssetPricingTimestamp": 0, + "groupApprovalTimestamp": 0, + "atFindNextTransactionFix": 0, + "newBlockSigHeight": 999999, + "shareBinFix": 999999, + "sharesByLevelV2Height": 999999, + "rewardShareLimitTimestamp": 9999999999999, + "calcChainWeightTimestamp": 0, + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0, + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 20, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 + }, + "genesisInfo": { + "version": 4, + "timestamp": 0, + "transactions": [ + { "type": "ISSUE_ASSET", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "ISSUE_ASSET", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + + { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "1000000000" }, + { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000" }, + + { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, + + { "type": "UPDATE_GROUP", "ownerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "TEST", "description": "test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "assetName": "OTHER", "description": "other test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "GOLD", "description": "gold test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + + { "type": "ACCOUNT_FLAGS", "target": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "rewardSharePublicKey": "7PpfnvLSG7y4HPh8hE7KoqAjLCkv7Ui6xw4mKAkbZtox", "sharePercent": "100" }, + + { "type": "ACCOUNT_LEVEL", "target": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "level": 5 }, + { "type": "REWARD_SHARE", "minterPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "rewardSharePublicKey": "CcABzvk26TFEHG7Yok84jxyd4oBtLkx8RJdGFVz2csvp", "sharePercent": 100 }, + + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 6 } + ] + } +} diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 5f439602..9168a0de 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -4,9 +4,13 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" }, + { "timestamp": 9999999999999, "fee": "1" } + ], "nameRegistrationUnitFees": [ - { "timestamp": 1645372800000, "fee": "5" } + { "timestamp": 0, "fee": "0.1" }, + { "timestamp": 9999999999999, "fee": "5" } ], "requireGroupForApproval": false, "minAccountLevelToRewardShare": 5, @@ -19,6 +23,8 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -73,7 +79,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, @@ -90,6 +102,8 @@ { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, + { "type": "UPDATE_GROUP", "ownerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "TEST", "description": "test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "issuerPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "assetName": "OTHER", "description": "other test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "GOLD", "description": "gold test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, diff --git a/src/test/resources/test-settings-v2-block-archive.json b/src/test/resources/test-settings-v2-block-archive.json index c5ed1aa8..209ce92d 100644 --- a/src/test/resources/test-settings-v2-block-archive.json +++ b/src/test/resources/test-settings-v2-block-archive.json @@ -9,5 +9,6 @@ "testNtpOffset": 0, "minPeers": 0, "pruneBlockLimit": 100, - "repositoryPath": "dbtest" + "repositoryPath": "dbtest", + "defaultArchiveVersion": 1 } diff --git a/src/test/resources/test-settings-v2-self-sponsorship-algo.json b/src/test/resources/test-settings-v2-self-sponsorship-algo.json new file mode 100644 index 00000000..5ea42e66 --- /dev/null +++ b/src/test/resources/test-settings-v2-self-sponsorship-algo.json @@ -0,0 +1,20 @@ +{ + "repositoryPath": "testdb", + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-v2-self-sponsorship-algo.json", + "exportPath": "qortal-backup-test", + "bootstrap": false, + "wipeUnconfirmedOnStart": false, + "testNtpOffset": 0, + "minPeers": 0, + "pruneBlockLimit": 100, + "bootstrapFilenamePrefix": "test-", + "dataPath": "data-test", + "tempDataPath": "data-test/_temp", + "listsPath": "lists-test", + "storagePolicy": "FOLLOWED_OR_VIEWED", + "maxStorageCapacity": 104857600, + "arrrDefaultBirthday": 1900000 +} diff --git a/start.sh b/start.sh index b3db54fe..a7a1419f 100755 --- a/start.sh +++ b/start.sh @@ -33,7 +33,8 @@ fi # Limits Java JVM stack size and maximum heap usage. # Comment out for bigger systems, e.g. non-routers # or when API documentation is enabled -# JVM_MEMORY_ARGS="-Xss256k -Xmx128m" +# Uncomment (remove '#' sign) line below if your system has less than 12GB of RAM for optimal RAM defaults +# JVM_MEMORY_ARGS="-Xss1256k -Xmx3128m" # Although java.net.preferIPv4Stack is supposed to be false # by default in Java 11, on some platforms (e.g. FreeBSD 12), diff --git a/TestNets.md b/testnet/README.md similarity index 59% rename from TestNets.md rename to testnet/README.md index e475e593..42e153d8 100644 --- a/TestNets.md +++ b/testnet/README.md @@ -2,9 +2,10 @@ ## Create testnet blockchain config -- You can begin by copying the mainnet blockchain config `src/main/resources/blockchain.json` +- The simplest option is to use the testchain.json included in this folder. +- Alternatively, you can create one by copying the mainnet blockchain config `src/main/resources/blockchain.json` - Insert `"isTestChain": true,` after the opening `{` -- Modify testnet genesis block +- Modify testnet genesis block, feature triggers etc ### Testnet genesis block @@ -25,6 +26,7 @@ - Make sure to reference testnet blockchain config file: `"blockchainConfig": "testchain.json",` - It is a good idea to use a separate database: `"repositoryPath": "db-testnet",` - You might also need to add `"bitcoinNet": "TEST3",` and `"litecoinNet": "TEST3",` +- Also make sure to use a custom `listenPort` (not 62391 or 12391) to ensure that transactions remain isolated to your testnet. ## Other nodes @@ -52,14 +54,13 @@ ## Single-node testnet -A single-node testnet is possible with code modifications, for basic testing, or to more easily start a new testnet. -To do so, follow these steps: -- Comment out the `if (mintedLastBlock) { }` conditional in BlockMinter.java -- Comment out the `minBlockchainPeers` validation in Settings.validate() -- Set `minBlockchainPeers` to 0 in settings.json -- Set `Synchronizer.RECOVERY_MODE_TIMEOUT` to `0` -- All other steps should remain the same. Only a single reward share key is needed. -- Remember to put these values back after introducing other nodes +A single-node testnet is possible with an additional settings, or to more easily start a new testnet. +Just add this setting: +``` +"singleNodeTestnet": true +``` +This will automatically allow multiple consecutive blocks to be minted, as well as setting minBlockchainPeers to 0. +Remember to put these values back after introducing other nodes. ## Fixed network @@ -93,3 +94,38 @@ Your options are: - `qort` tool, but prepend with one-time shell variable: `BASE_URL=some-node-hostname-or-ip:port qort ......` - `peer-heights`, but use `-t` option, or `BASE_URL` shell variable as above +## Example settings-test.json +``` +{ + "isTestNet": true, + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "dogecoinNet": "TEST3", + "digibyteNet": "TEST3", + "ravencoinNet": "TEST3", + "repositoryPath": "db-testnet", + "blockchainConfig": "testchain.json", + "minBlockchainPeers": 1, + "apiDocumentationEnabled": true, + "apiRestricted": false, + "bootstrap": false, + "maxPeerConnectionTime": 999999999, + "localAuthBypassEnabled": true, + "singleNodeTestnet": true, + "recoveryModeTimeout": 0 +} +``` + + +## Quick start +Here are some steps to quickly get a single node testnet up and running with a generic minting account: +1. Start with template `settings-test.json`, and `testchain.json` which can be found in this folder. Copy/move them to the same directory as the jar. +2. Set a custom `listenPort` in settings-test.json (not 62391 or 12391) to ensure that transactions remain isolated to your testnet. +3. Make sure feature triggers and other timestamp/height activations are correctly set. Generally these would be `0` so that they are enabled from the start. +4. Set a recent genesis `timestamp` in testchain.json, and add this reward share entry: +`{ "type": "REWARD_SHARE", "minterPublicKey": "DwcUnhxjamqppgfXCLgbYRx8H9XFPUc2qYRy3CEvQWEw", "recipient": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "rewardSharePublicKey": "CRvQXxFfUMfr4q3o1PcUZPA4aPCiubBsXkk47GzRo754", "sharePercent": 0 },` +5. Start the node, passing in settings-test.json, e.g: `java -jar qortal.jar settings-test.json` +6. Once started, add the corresponding minting key to the node: +`curl -X POST "http://localhost:62391/admin/mintingaccounts" -d "F48mYJycFgRdqtc58kiovwbcJgVukjzRE4qRRtRsK9ix"` +7. Alternatively you can use your own minting account instead of the generic one above. +8. After a short while, blocks should be minted from the genesis timestamp until the current time. \ No newline at end of file diff --git a/testnet/settings-test.json b/testnet/settings-test.json new file mode 100755 index 00000000..e49368f8 --- /dev/null +++ b/testnet/settings-test.json @@ -0,0 +1,18 @@ +{ + "isTestNet": true, + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "dogecoinNet": "TEST3", + "digibyteNet": "TEST3", + "ravencoinNet": "TEST3", + "repositoryPath": "db-testnet", + "blockchainConfig": "testchain.json", + "minBlockchainPeers": 1, + "apiDocumentationEnabled": true, + "apiRestricted": false, + "bootstrap": false, + "maxPeerConnectionTime": 999999999, + "localAuthBypassEnabled": true, + "singleNodeTestnet": false, + "recoveryModeTimeout": 0 +} diff --git a/testnet/testchain.json b/testnet/testchain.json new file mode 100644 index 00000000..089bd693 --- /dev/null +++ b/testnet/testchain.json @@ -0,0 +1,2664 @@ +{ + "isTestChain": true, + "blockTimestampMargin": 2000, + "transactionExpiryPeriod": 86400000, + "maxBlockSize": 2097152, + "maxBytesPerUnitFee": 1024, + "unitFees": [ + { "timestamp": 0, "fee": "0.001" } + ], + "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "1.25" } + ], + "useBrokenMD160ForAddresses": false, + "requireGroupForApproval": false, + "defaultGroupId": 0, + "oneNamePerAccount": true, + "minAccountLevelToMint": 1, + "minAccountLevelForBlockSubmissions": 1, + "minAccountLevelToRewardShare": 2, + "maxRewardSharesPerFounderMintingAccount": 10, + "maxRewardSharesByTimestamp": [ + { "timestamp": 0, "maxShares": 10 } + ], + "founderEffectiveMintingLevel": 10, + "onlineAccountSignaturesMinLifetime": 43200000, + "onlineAccountSignaturesMaxLifetime": 86400000, + "onlineAccountsModulusV2Timestamp": 0, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 1692554400000, + "rewardsByHeight": [ + { "height": 1, "reward": 5.00 }, + { "height": 259201, "reward": 4.75 }, + { "height": 518401, "reward": 4.50 }, + { "height": 777601, "reward": 4.25 }, + { "height": 1036801, "reward": 4.00 }, + { "height": 1296001, "reward": 3.75 }, + { "height": 1555201, "reward": 3.50 }, + { "height": 1814401, "reward": 3.25 }, + { "height": 2073601, "reward": 3.00 }, + { "height": 2332801, "reward": 2.75 }, + { "height": 2592001, "reward": 2.50 }, + { "height": 2851201, "reward": 2.25 }, + { "height": 3110401, "reward": 2.00 } + ], + "sharesByLevelV1": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.05 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.10 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.15 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.20 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.25 } + ], + "sharesByLevelV2": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.06 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.13 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.19 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.26 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.32 } + ], + "qoraHoldersShareByHeight": [ + { "height": 1, "share": 0.20 }, + { "height": 1010000, "share": 0.01 } + ], + "qoraPerQortReward": 250, + "minAccountsToActivateShareBin": 30, + "shareBinActivationMinLevel": 7, + "blocksNeededByLevel": [ 50, 64800, 129600, 172800, 244000, 345600, 518400, 691200, 864000, 1036800 ], + "blockTimingsByHeight": [ + { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } + ], + "ciyamAtSettings": { + "feePerStep": "0.00000001", + "maxStepsPerRound": 500, + "stepsPerFunctionCall": 10, + "minutesPerBlock": 1 + }, + "featureTriggers": { + "atFindNextTransactionFix": 0, + "newBlockSigHeight": 0, + "shareBinFix": 0, + "sharesByLevelV2Height": 0, + "rewardShareLimitTimestamp": 0, + "calcChainWeightTimestamp": 0, + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 9999999999999, + "disableReferenceTimestamp": 0, + "aggregateSignatureTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 9999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 1678622400000 + }, + "genesisInfo": { + "version": 4, + "timestamp": "1677572542000", + "transactions": [ + { "type": "ISSUE_ASSET", "assetName": "QORT", "description": "QORTAL coin", "quantity": 0, "isDivisible": true, "data": "{}" }, + { "type": "ISSUE_ASSET", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "ISSUE_ASSET", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + + { "type": "REWARD_SHARE", "minterPublicKey": "HFDmuc4HAAoVs9Siea3MugjBHasbotgVz2gsRDuLAAcB", "recipient": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "rewardSharePublicKey": "F35TbQXmgzz32cALj29jxzpdYSUKQvssqThLsZSabSXx", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "HmViWJ2SMRVTYNuMvNYFBX7DitXcEB2gBZasAN3uheJL", "recipient": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "rewardSharePublicKey": "8dsLkxj2C19iK2wob9YNDdQ2mdzyV9X6aQzfHdG1sWrp", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "79THiqG9Cftu7RFEA3SvW9G4YUim7qojhbyepb68trH4", "recipient": "QS7K9EsSDeCdb7T8kzZaFNGLomNmmET2Dr", "rewardSharePublicKey": "BuKWPsnu1sxxsFT2wNGCgcicm48ch4hhvQq9585P2pth", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "KBStPrMw84Fr84YJG5UQEZkeEzbCfRhKtvhq1kmhNJU", "recipient": "QP9y1CmZRyw3oKNSTTm7Q74FxWp2mEHc26", "rewardSharePublicKey": "6eW63qGsiz6JGfH4ga8wZStsYpU2H3w7qijHXr2JADFv", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "C9iuYc8GB9cVNNPr28v7pjY1macmsroFYX65CTVPjXLn", "recipient": "QNxfUZ9xgMYs3Gw6jG9Hqsg957yBJz2YEt", "rewardSharePublicKey": "4LvsURDbDhkR3f9zvnZun53GEtwERPsXLZas5CA4mBPH", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "DwcUnhxjamqppgfXCLgbYRx8H9XFPUc2qYRy3CEvQWEw", "recipient": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "rewardSharePublicKey": "CRvQXxFfUMfr4q3o1PcUZPA4aPCiubBsXkk47GzRo754", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "8ZHT347rPzCY8Jmk9R2MTEm1c2t6zLGjSU8nKQh4JgBt", "recipient": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "rewardSharePublicKey": "BSatVDRBBzeSMwXfDU7ngjVLhUFfS3CTpdmBWb2wCSU", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "BqWV8eMDUxAJ7FEcjQZzCsNKi6TggwYd7yQHWtmYJLWd", "recipient": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "rewardSharePublicKey": "AZBGQ6pVcH8KHBRuqNyBZSkFRedida8GdjoPJvDbgXtn", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "ELt8dgskQ9zfwF9dwVYwjq2zXFExstRJoPD4gCC4991d", "recipient": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "rewardSharePublicKey": "C6aVBbUHy8nAS3wYQo6jdWFTBagmqrh3JhRo8VH5k1Bx", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "Btqz7ug1XEMMun8hXZHVZWctRZxMKYeExsax7ohgzGNE", "recipient": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "rewardSharePublicKey": "CdVq4RwirHMjaRkM38PAtMvLNkokqYCiu2srQ3qf7znq", "sharePercent": 0 }, + + { "type": "ACCOUNT_FLAGS", "target": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPsjHoKhugEADrtSQP5xjFgsaQPn9WmE3Y", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbmBdAbvmXqCya8bia8WaD5izumKkC9BrY", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QhPXuFFRa9s91Q2qYKpSN5LVCUTqYkgRLz", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPFM4xX2826MuBEhMtdReW1QR3vRYrQff3", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSJ5cDLWivGcn9ym21azufBfiqeuGH1maq", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTx98gU8ErigXkViWkvRH5JfaMpk8b3bHe", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcPro2T97Q8cAfcVM4Pn4fv71Za4T6oeFD", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbJqEntoBFps7XECQkTDFzXNCdz9R2qmkB", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qc5sZS1Vb1ujj8qvL5uXV5y5yQPq6pw2GC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QceNmCiZxxLdvL85huifVcnk64udcJ47Jr", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qd453ewoyESrEgUab6dTFe2pufWkD94Tsm", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QfjoMGib4trpZHzxUSMdmtiRnsrLNf74zp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qgfh143pRJyxpS92JoazjXNMH1uZueQBZ2", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QicRwDhfk8M2CGNvpMEmYzQEjESvF7WrFY", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLwMaXmDDUvh7aN5MdpY28rqTKE8U1Cepc", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP3J3GHgjqP69neTAprpYe4co33eKQiQpS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPsjHoKhugEADrtSQP5xjFgsaQPn9WmE3Y", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRt11DVBnLaSDxr2KHvx92LdPrjhbhJtkj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRTygRGv8XxTeP34cgQqwfCeYBGu3bMCz1", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSbHwxaBh5P7wXDurk2KCb8d1sCVN4JpMf", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTE6b4xF8ecQTdphXn2BrptPVgRWCkzMQC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTKKxJXRWWqNNTgaMmvw22Jb3F5ttriSah", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUxh6PNsKhwJ12qGaM3AC1xZjwxy4hk1RG", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS7K9EsSDeCdb7T8kzZaFNGLomNmmET2Dr", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLxHu4ZFEQek3eZ3ucWRwT6MHQnr1RTqV3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qe3DW43uTQfeTbo4knfW5aUCwvFnyGzdVe", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNxfUZ9xgMYs3Gw6jG9Hqsg957yBJz2YEt", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQXSKG4qSYSdPqP4rFV7V3oA9ihzEgj4Wt", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QMH5Sm2yr3y81VKZuLDtP5UbmoxUtNW5p1", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRKAjXDQDv3dVFihag8DZhqffh3W3VPQvo", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXQYR1oJVR7oK5wzbXFHWgMjY6pDy2wAhB", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNyhH8dutdNhUaZqnkRu5mmR7ivmjhX118", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qj1bLXBtZP3NVcVcD1dpwvgbVD3i1x2TkU", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjNN6JLqzPGUuhw6GVpivLXaeGJEWB1VZV", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbgesZq44ZgkEfVWbCo3jiMfdy4qytdKwU", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QgyvE9afaS3P8ssqFhqJwuR1sjsxvazdw5", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRt2PKGpBDF8ZiUgELhBphn5YhwEwpqWME", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRZYD67yxnaTuFMdREjiSh3SkQPrFFdodS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QieDZVeiPAyoUYyhGZUS8VPBF3cFiFDEPw", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QV3cEwL4NQ3ioc2Jzduu9B8tzJjCwPkzaj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNfkC17dPezMhDch7dEMhTgeBJQ1ckgXk8", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcdpBcZisrDzXK7FekRwphpjAvZaXzcAZr", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qaj7VFnofTx7mFWo4Yfo1nzRtX2k32USJq", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRchdiiPr3eyhurpwmVWnZecBBRp79pGJU", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QemRYQ3NzNNVJddKQGn3frfab79ZBw15rS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QW7qQMDQwpT498YZVJE8o4QxHCsLzxrA5S", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QM2cyKX6gZqWhtVaVy4MKMD9SyjzzZ4h5w", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qfa8ioviZnN5K8dosMGuxp3SuV7QJyH23t", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS9wFXVtBC4ad9cnenjMaXom6HAZRdb5bJ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSRpUMfK1tcF6ySGCsjeTtYk16B9PrqpuH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qez3PAwBEjLDoer8V7b6JFd1CQZiVgqaBu", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP5bhm92HCEeLwEV3T3ySSdkpTz1ERkSUL", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZDQGCCHgcSkRfgUqfG2LsPSLDLZ888THh", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QN3gqz7wfqaEsqz5bv4eVgw9vKGth1EjG3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QeskJAik9pSeV3Ka4L58V7YWHJd1dBe455", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXm93Bs7hyciXxZMuCU9maMiY6371MCu1x", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWTZiST8EuP2ix9MgX19ZziKAhRK8C96pd", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcNpKq2SY7BqDXthSeRV7vikEEedpbPkgg", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QhX25kdPgTg5c2UrPNsbPryuj7bL8YF3hC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qcx8Za7HK42vRP9b8woAo9escmcxZsqgfe", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjgsYfuqRzWjXFEagqAmaPSVxcXr5A4DmQ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXca8P4Z6cHF1YwNcmPToWWx363Dv9okqj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjQcgaPLxU7qBW6DP7UyhJhJbLoSFvGM2H", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjaJVb8V8Surt8G2Wu4yrKfjvoBXQGyDHX", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QgioyTpZKGADu6TBUYxsPVepxTG7VThXEK", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcmyM7fzGjM3X7VpHybbp4UzVVEcMVdLkR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiqfL6z7yeFEJuDgbX4EbkLbCv7aZXafsp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QM3amnq8GaXUXfDJWrzsHhAzSmioTP5HX4", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWu1vLngtTUMcPoRx5u16QXCSdsRqwRfuH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qi2taKC6qdm9NBSAaBAshiia8TXRWhxWyR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZko7f8rnuUEp8zv7nrJyQfkeYaWfYMffH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcJfVM3dmpBMvDbsKVFsx32ahZ6MFH58Mq", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVfdY59hk6gKUtYoqjCdG7MfnQFSw2WvnE", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qhkp6r56t9GL3bNgxvyKfMnfZo6eQqERBQ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjZ9v7AcchaJpNqJv5b7dC5Wjsi2JLSJeV", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWnd9iPWkCTh7UnWPDYhD9h8PXThW5RZgJ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QdKJo8SPLqtrvc1UgRok4fV9b1CrSgJiY7", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcHHkSKpnCmZydkDNxcFJL1aDQXPkniGNb", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjaDRfCXWByCrxS9QkynuxDL2tvDiC6x74", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS4tnqqR9aU7iCNmc2wYa5YMNbHvh8wmZR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiwE9h1CCighEpR8Epzv6fxpjXtahTN6sn", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRub4MuhmYAmU8bSkSWSRVcYwwmcNwRLsy", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLitmzEnWVexkwcXbUTaovJrRoDvRMzW32", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUnKiReHwhg1CeQd2PdpXvU2FdtR9XDkZ4", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcSJuQNcGMrDhS6Jb2tRQEWLmUbvt5d7Gc", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQQFM1XuM8nSQSJKAq5t6KWdDPb6uPgiki", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWnoDUJwt6DRWygNQQSNciHFbN6uehuZhB", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZppLAZ4JJ3FgU1GXPdrbGDgXEajSk86bh", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNHocuE5hr64z1RHbfXUQKpHwUv3DG4on4", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS5SMHzAyjicAkMdK7hnBkiGVmwwBey1kQ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QhauobwGUVNT8UkK41k2aJVcfMdkpDBwVb", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qh31pAfL5dk7jDcUKCpAurkZTTu27D9dGp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QM1CCBbcTG2S6H1dBVJXTUHxhfasfTR6XF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQ5zUwBwfGBru68FsaiawC5vjzigKYzwDs", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWmFjyqsHkXfXwUvixzXfFh8AX5mwhvD7b", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTJ8pBwaXUZ1C7rX4Mb9NWbprh88LeUsju", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QMLDPdpscAoTevAHpe3BQLuJdBggsawGLC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QaboRcMGnxJgfZDkEpqUe8HXsxFY6JdnFw", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVUTAqofenqSuGC9Mjw9tnEVzxVLfaF6PH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVCDS2qjjKSytiSS2S6ZxLcNTnpBB9qEvS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QfEtw43SfViaC2BEU7xRyR4cJqPdFuc547", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qf9EA2o8gMxbMH59JmYPm8buVasBCTrEco", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QddoeVG1N97ui2s9LhMpMCvScvPjf2DmhR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QajjSZXwp33Zybm9zQ62DdMiYLCic4FHWH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZVs7y4Ysb62NHetDEwH7nVvhSqbzF3TsF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP6eci8SRs7C6i1CTEBsc7BkLiMdJ7jrvL", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QgUkTPpwsdyes7KxgYzXXWJ1TnjUFViy9R", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVVUs58P3UimAjoLG3pga2UtbnVhPHqzop", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QYVhnvxEQM3sNbkN5VDkRBuTY3ZEjGP2Y6", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qgfcck7VX4ki9m7Haer3WSt9a6sEW7DwKm", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qdwd54nUp5moiKVTQ7ESuzdLnwQ9L7oT37", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiPTyt2VgN7sJyK2rCfy24PQhoL1VwvAUs", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXNABfSfAFRDF2ZCca4tf1PyA3ARyLUEUK", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZJjUVgjoacvHmdjfqUDq3Dh6q3eTyNh2y", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWHzcbXSrEg7AiVDLBhsR1zUBnWUneSkUp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLgjnrRRCkQt7g7pWQGAXg99ZxAC8abLGk", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPmFGR56aQ586ot61Yt1LX79gdgBYGNeUN", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQb493uqAUrWe2YoNR8MmhhxjNYgcf3XS6", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QV3UDtxFyXCsKdmnVWstWQc1ZMSAPp1WNE", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QV527xbvZNT1529LsDBKn22cNP9YJ6i3HF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQAbKyRGv8RUytDyr1D6QzELzMvNmGnuhZ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP2xZTDDu6oVvAaRjTNW7fBEm9fcjmyjAF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRH9E99H893PS8hFmzPGinAQgbMmoYxRKj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRtqR9AqsaE4TKdH4tJPCwUgJtKXkrzumk", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QaEyGRLnR7o85PCRoCq2x4kmsj1ZuVM3eo", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUZSHjxYNfa6nF8MSyiCm5JKbiRnBy6LZd", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXUozAco8vrZgc3LZDok4ziQdUb1F2WNiv", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZF252FDKhrjdXUiXf16Kjju3q23aNfXWk", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qj1odhqTstQweB9NosXVzY6Lvzis24AQXP", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTaiJKCnV9bfbEbfbuKnxzNU8QEnYgv4Xu", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QYLdKUKoKvBAFigiX2H7j1VcL8QaPny1XX", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QaEfP6nFkNrDuzUbcHWj9casn9ekRJCtrg", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcbQcC2BZP9AipqSDFThm3KWfycn9jweVj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QfGLmDwWUHhpHFebwCfFibdXFcMZhZWepX", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QMkUwfBU1HKUius1HrEiphapMjDBsFrJEd", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qab7N4CYsATCmy8T3VTSnG8oK3Uw3GSe6x", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QdJirbcRUTZ4M6fBAmKGgsvC7DVpEqQLrt", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSPVSpKZueM1V9xc8HD9Qfte5gFrFJ61Xv", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcuAciBq8QjDS2EMDAMGi9asP8oaob7UFs", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNwDgR34mYsw1t9hzyumm5j7siy8AMDjST", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qf5RGjWtSn8NSpYeLxKbamogxGST3iX3QY", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QYrytjgXZmWsGarsC3qAAVYdth8qpEjjni", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbHqojw2kSmcsdcVaRUAcWF2svr9VPh1Lf", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbpZL12Lh7K2y6xPZure4pix5jH6ViVrF2", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUZbH7hV8sdLSum74bdqyrLgvN5YTPHUW5", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiNKXRfnX9mTodSed1yRQexhL1HA42RHHo", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QT4zHex8JEULmBhYmKd5UhpiNA46T5wUko", "andMask": -1, "orMask": 1, "xorMask": 0 }, + + { "type": "ACCOUNT_LEVEL", "target": "QXsrAcNz93naQsBcyGTECMiB3heKmbZZNT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QN4NnUvf4UwCKz9U66NUEs6cQJtZiHzpsB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRXzd5xi7nPdqZg5ugkoNnttAMEMAS7Zgp", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZmFAL7D719HQkV72MnvP2CEsnBUyktYEX", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QT7uWcs2dacGGfLzVDRXAWAY5nbgGjczSq", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYhu1Yvx4wEcMZPF7UhRNNfcHFqWKU9y8U", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeEY7UgPBDeyQnnir53weJYtTvDZvfEPM4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQszFsHkwEf1cxmZkq2Sjd7MmkpKvud9Rc", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi8AKfUEZb6tFiua3D7NMPLGEd8ouyAp99", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYMortQDHVwAa44bfZhtoz8NALW3iE9bqm", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMptfhifsYG7LzV9woEmPKvaALLkFQdND4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR48czk5GXWj8nUkhzHr1MmV9Xvn7xsyMJ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRmrBWDmcRz1c5q63oYKPsJvW5uVvXUrkt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR24APnqsTaPCS5WFVEEZevk7oE1TZdTXy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPUgbXEj1TfgLQng6yHDMnV4RE4fkzxneP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhZH9dcBwJXRHTMUeMnnaFBtzyNEmeEu95", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeALW9oLFARexJSA5VEPAZR1hkUGRoYCpJ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgxx7Xr4Ta9RBkkc5BHqr6Yqvb38dsfUrT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcqiXKsCnUst4qZdpooe4AuFZp6qLJbH1E", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQLd58skeFGRzW9JBYfeRNXBEF6BbxuRcL", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfBvQKMgWjix4oXPZrmU9zJDv8iCT4bAuv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QamJduVxVwqkUugkeyVwcEqHSSmPNiNt4G", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeYPPuzXey13V2nRZAS1zhBvsxD9Jww8br", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QiKu8wuB5rZ4ZvUGkdP4jTQWBdMZWQb4Ev", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhhhQhVeJ1GL3oMyG2ssTx7XLNhPSDhSTs", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPfi9t9CAPVHu3FGxRGvUb723vYFUYQEv6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWH9V5WBEvVkJnJPMXkULX9UaDwHGVoMi6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYWoBSTXCRmYQq1yJ3HHjYrxC4KUdVLpmw", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QftjmqLYfjS4jwBukVGbiDLxNE5Hv5SFkA", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMAJ2jt377iFtALB3UvuXgg21vx9i3ASe9", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaP9FzoAQAXrvSYpiR9nQU6NewagTBZDuB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZpWpi8Lp7zPm63GxU9z2Xiwh6QmD4qfy2", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPNtFMjoMWwDngH94PAsizhhn3sPFhzDm6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTkdeWxc34v5w47SDJYC9QFz9t4DRZwBEy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSSpbcy65aoSpC3q5XwEjSKg15LG868eUe", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhcfCJ6nW4A6PztJ5NXQW2cUo67k2t4HHB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYqv8RVp57C9gaH8o1Fez3ofSW24RAfuju", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVgLvwFNNjHAUwE8h2PcfKRns1EebHDX4B", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSv5ZY5mW7aGbYA7gqkj4xyPq4AECd7EL8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgyQ9HX5JRbdKxFTXgsoq2cnZD89NwxinT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTEpaAMni8SpKY8fd8AF7qXEtTode1LoaW", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeKBjbwctfydGS6mLvDSm8dULcvLUaorwX", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhfG4EVSd8iZ8H1piRvdRC8MDJ3Jz1WcN9", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjXYs5HWfda3mgTBqveKatTWHnahv2oX22", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh1iJg1BEdoK4q4hjXcSkNE4qv9oYsHoF4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPBcSVqzpB3QhiwMkiq9rMHe7Mx5NynXnD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUgVsyMPFxjiS2o5y81FoXoiWHiAwfbq94", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNH3ebZTv6GeWwjwhjhGg7doia6ZJjqQXG", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXXeoduLPuhfURibgkfEfSSQ2Rom9SELtL", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcKnXTjEaTBr91PQY7AkCxvChNpkqU6r1t", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhWNbSmPAoAg8bXirPeNyGVuoSk84rfnHu", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QdivX7dtJKosr83EmLTViz7PkFC4FQqeH4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUB6fPHDTrpYyU6wJmAqV6TUBZiWLrTPuz", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QiG69VVGp13oCiryF4vpDu3a2kEEHi7HDm", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS4dJJhwCheoMB3Z8Mk8wNZFfSu4FkW9Vv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb6peGujesgEH9aHd19NfKvR5vTmsb2oHM", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh5tovSQykjFNJGV1P7tGtfmfnJXQQNLr7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS1vPBzGLu8ZskZtapcYzUCr8pEjVxtFgu", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUJmfReuva7PmyzFBr7M35QuYZcAoeWPyT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QLfYVnUtR4RVcthhzYc7U76vmK6LkyUky4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfeNQecGDhdHSdoTDAKAaAdpmgGBfJjQw6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QiPHkz2YVDhsJPdkD7qxizFFEu7m3g3zA7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSmmCGNkGbqwGGvdeBtkHBPa4pXXEG2vkf", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNjN51iZaZb3ZnfNiLdm1xtUZ4DKLj9X7e", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjFocrHNieQ8rDYifrZTWtYgejjih6mmS1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSNDFgL3bfX7Pe9FaD7p1G1rtJe5v9aYsV", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWf8uFUXCahEXLV2cjJjunimCJdnvsN3JM", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSzVqpvkjfFAC6sJcyefyouP1zYZycvwpm", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QX3zQTmhnm89PrW1nfs6YJDfiAkegzpD1S", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg6kovZCzF2GKNyMoeJSaUArvzKJJH56L1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhP2ND6q5Sptsy5pQUo18AuTgKMBfF4aPr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QT3Cu76gET1ezemDVCojoP3SLMY4xNDH7k", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QdsqubwFQ1hChYwzpHvKAiLF9JMWWEwXhp", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXE9M12CjPHBSFTS8DFUWjab4Z7F1JeRw1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa5e8Pz4sM7RSAbwvM2N9m5NyYAgm2Fo3J", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjoNujTmVCDVoR5M99NMBrGwuJCVZUSWJ2", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMmVSM2dmfhRjGMCZaLeBGU7kXGGPeiRZn", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYh1Ht5c278CPs56khy4iH2YxXZrtdMGXo", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb3m52qr4jcsidw6DTPJUC62b51rM61VFj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTC76DrGsCJuT4ybDiDTFaTxjXTPUJcpUi", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRqBqahzem4MpJarmGYh1jyaFHYxufssY3", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZpoY1W7MJvu5uJwdJRbKwWBhVhYPRAgag", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPfvtXRAWazxK8CrSRvDoCtRG6Hy3ujCx4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgJ8Ud1qJHfdC6wyaUNcigUHJ65Udd2jYh", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRQtHawUKGY7g68yabnneKo88BFv35ddMD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYFKaYFjRe8iYDbwUBTWjmPGosjcgBtC3Y", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc3HUdiKbHaaFK83p44WVicewmZip1TnAj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYwkYWDsoJAWHPN1dHttMZ8QPABbriRMov", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVNwVTRnJNL7HYpHZ7wppApTv8H3FxvPXU", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaxHEi7urRTZbGmcpyCcJr6zQZbDAnbfJt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPqG2UHH3ueqsjm2HMUuQj6GQW99VVXJry", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc5LxN2SQCQfJLVatuSMtmJtAihjapL3Qg", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgMgsYiwyRiUYMHKCdB5tLJxuCroEbJnq8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS5NyYUUPuPvkkvazYyYjTT9ef7eZU8of8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfNd6YADJq1M4SbwBxLKQ3AD7GEpTpAJi7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZfGRwx8K1AyYwPUXHa9Tn16KP2h54iwfr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNB1kaRHYBrmDRHepqxad5DYxQPbjVG4As", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTpjvRCrvWjXoBzSG379ZsEwW2F5xoLSiP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZhnNK5FfX3FjTwwYwbewUQGE64Vts7qXP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNFmGsWLr7Y4qngz1maq4ptzhcUAJdjDU1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR3hH2cxYz9MgDBq3vthEbdnFVMJvprzyV", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjjDaHSiAaPP8p3CRM3STeBc4VD9SCY4TP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRppWy5shqf6TPZfh6CAfjPB25aLWPiNub", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgaszp8eniCvsFiVHaBNNDToaVVYjLdLeB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbdHAJur3Vg9MYCPcgsz4dNW9gDGp1f727", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTxt6nMZmyZCJVLcsxZmwt4sUv1bFkLLRi", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVbpnTE83PfopgvXY9TD92aYWQrTgvGN3Q", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPdfyB2zwWt77X5iHeAKr8MTEHFMHE3Ww3", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRUgU6YptQd85VWiSvLUDRoyxnTBPGRHdx", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRLDb39eQWwiqttkoYxDB5f5Bu8Bt6tu8P", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUB67e2qPecWexgCB98gr3oHqMN2ZVay9j", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhK3xN3Ut6W1B5pg9MJdTLyHLAGLjcP7ma", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qai9X8cd9FdZufFH5rcKYodp6s4AQqH2XF", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QX2YaXwfrEDNzUAFWRc3D17hDaLAXw42NQ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMUMUzgWeXhUJsWxa7DWVaXDzJFrtpuPCn", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg8hVCdNiRy7Tqs2EHqLWtydqp1wzYc7Ny", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXG8YWRehGa3aLTnnMupmBrXeXS93YuwmE", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qadt3251BYugMm2MjkmzCjrzGp2MfkJicH", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaiosSXjrXXca8vLpNwKh8qijdh1rd23L3", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QP5tVLY8CQqQgzMuTPrxz2XpP2KDL9neNV", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjeebj5TZqG3y8yGwWT7oamPxEncaf5fC4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMM6TbkySGcRkxdpjnmeRcYgL1oC5JKR7X", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQAFHDRg2PyR6UMR87T2DkQfizMR5VhStM", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQcXsQRpHtPjECVp55Weu4ohoJK6pK81vu", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSrT7WTzjs6jnwZpDmcD6NvD2V3i4H5tq5", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWAagG61SiQvfSbWS4vQnvmJbyCJ7GSXiy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQP64bevncP8kZ9bxVP5Brp8moK1rsPsBk", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUuGEuWwQyjMgtxzAhcvmsQhE8VzsA3vjt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa7iysVRdxo3KzYSi6JAqAYf4NFfFDjWLj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcQdJHRGgvL3AoR9LSRSjVNdczukw7PKQe", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QedHYrn1QkrRZBkRu5kkajgqh5bcD8xZkt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUChBcWdxZX1VFHGwrUjRJbqbXjRdPNyki", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNW6zHWRyzaMPbb6JbKciobqbxtuQSZgw4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPFA5p13WYzzhpvHCGDoHtiA2oKAxPeKhU", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS2ekPtGMR2obKdFKqFAcJQ3rbZmrzBSRz", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSpVwNfiKEh3NiBXduS8TnJXwgyHYmfFqH", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYm6g3WqAKnhotVwSLjqzorpVhzn2LgctL", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSfifb4e9W8C1K2uaAcwvjzqN33fmMcVwR", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYrLivTTHat8xFeJKkzrJXSyHeWkuBhWVA", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTxcZ3kMi7msQCkViFwWLdhkShhNNVa5Wv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNS4HmJen6qDVqSAYszeHKfaf1j1662tj6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPBri2D8WYjxVZYd2oKgwvXg94FKweytBQ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbeujwVbYFLx5uQBmkYs1a6cZRAopeB4cD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWQUSSqBRQBiNnDu3ZGNGTXJyAfbLf5MxK", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPt5bnE51SzA6VES5kpdvpNHiFeHHMKWc8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdu3N57EXxaZ8TXRfHbEa8QuqbYW2sot1t", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QU29ppZiJ9Vzw4tQBrXdPJZToWhpu9Dp9Q", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbrkBLFcmRUA21u5QsrPBpzrDH2wXpK7V", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QP544WzvAVh72cCVGr2WKFMzpicaH1wqAY", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgKUwvnhj8tHWbNb59s9nkHQdapgWNcgAy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSCminTT9z7qmx3zEvGZ221B5rVNvjBsK4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaSrZL9TyKNUMfge6YiDatURrT2QHxNX1R", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYg2fLR5jXjStMhzUSq7QJ5uEbTrvRXRYt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUWZa7s85qeLC6uWKTsMXnJ4BQbMiBddZB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbUSdFauEMKMHq2kAfX7BaLknVME6FpJhj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRXMXw7CT1NahXwj19t8wHHAuUFAMYm6NK", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWr5TR1trHvVh1JzQbRARKqjJaMiywYzgr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSjVVpSLeaaFcV1XacFJUXpBoBB3paFVPY", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQaDSZPWWFcFPGj38g63aP2gngvcgJnmsa", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcKk4AGz4FwYA56C7wAZW9Ep5Fimf4c1Mo", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQJnvY3h86m56EGfWKzaVZnFthNDAUdYFo", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSY6Ps3vxs1XEyFugvAWnv8a7sd1WuZkA8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjGbYagnZyc38Sm2M7gbg7wNX4Tfp6kTSs", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QikfKyFmSWN12cMHVzEurCrfS4KEywessZ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZRnbiNgLsGjd4pCrWntwSaGU3Ex4sZfLE", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTyUpPTd4n3Qk9k6k6ifKnB79XHueE4M4X", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QftHwRjwREQ3goEzehhF59rZUtrqrBGH7P", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhy15ZCfvjcDiQt97YcipgwK3paNQWfSAT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfhGGYFr8ANfCg32VcvULCqcofUybRbHYJ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMZqtWiJjH1JUqy7roNi95ByGvzFThxDXy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR17PHMYpHsfhQ8NXPVSVzXG3puMn99YfU", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVJKdAoPLfnShJFk1cxcu8h7z1SvPTaVyg", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPByWaTGBToyDNhGMMBgGGRtLPD9V4h5Vv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVNWqbd7ERjn9dcqBGwmUcseoiwQCehey7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUQiDpv4PzjHLz8bYk8FJBnzrmjKYc6bsr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXtNoe9v7bsfW6w8uJweXpo4JESHoxWium", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfPfxADaYrQUrKySf6tJBtMHA8cNG7VtNe", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQjtX9bro4bRkS1B3FyfAihyk3vZkQm8hZ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qidvt5WQVMqgcchxwGdCd2jp4cCdGioA4H", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhfLqEaDKmbynhKYK95BQJtseH3cqEEURD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYyyAFBUXB9F91KwHCQNuFDGuw7L38fi4x", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbMdmYjG4d71FUjk6L7pEoszoC9EQH1zUN", "level": 1 }, + + { "type": "ACCOUNT_LEVEL", "target": "QWxFeuRWE5GZXNfZ2tYqW3GmAC3FAz5Qrc", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbG62vQnBrtYJ2VwuJSzfA8NXMj36FYbb", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QLoV9cxAUkPn2DaQKnqDVJq6jMN3k21JAM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QcsBjck2WTR7J3PmQ9RXHxsPewPkbxzCtp", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj29EdPyW7MhZ15XDgvGZwXrmsP84KM5ff", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUTg3JNn6JGtHy25XTgdNu5APzp5cAg79v", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QU1C597JwXXBbR2ysX4fKGr9DTqbn1bPxE", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgboEdXscGVZ3pFyUq7x9ufaRmDseeb4dC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNWtJX7SDYBQxsEqmjsLbhVQoAYV3QkynD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY1KfMNNtBe1q6JxGzGimxM3vpCoqzQCNX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYp7DknXc9PbdF52vTozrh1ZEfM7wZBhFG", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfshJREL1rFXcBDYTZQcj8mGLpQh3ZWC6t", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNLBihtJXLo3HVjzLGgdbgbHacTgMt3USC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZkKsgF78HsiDef87g7dGLGKsoTSH2ekWT", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWv4gyJ4N1WxCLvAmWKLtx5mmBYAqXHTXD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSp5oQ65SWNbfampnxzgBuEymJLVkarPBV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QLddFbuRfbkrMQnpHA3gvBtYERfqwRdJsC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMHWrDejEvBVuzQyUhnVqnSaKKMHyCosyF", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVwFkDM51dcvCfmvYUBjjQg87JteNis7f6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTvu4zok2UGnB45s1Luj6v3AzMRUEP1zmd", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYh6BPhuScCt9ENbnAcp16mCZLsYnukMWY", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYH3WNEknRKSFViWuZzmN43q8wkAGpzKXu", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZwweWZAURCtoLM8K1ouA7McNyHNjyDcBi", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ5RBZkiqGhvCnQvCPPZar8RqhtwDonDBi", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWpt9ZPYks3PE8nHLyKkoLogD3doMumrK6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdds3wCmA2P4kkMXHJCi1JuQVMLJayskQu", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYkkDoVQCHZQM3KJQC1J8qFVZmXi3T7JZe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QM44Ks8EALor7MNhQGHpUpqu484VeUYRAL", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeBeZzP6xxSk1hem3tRzchzAAMgRKb3fkg", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QThNX1VbEGbAE31sjZKZYBBg4CNX5JkbRr", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQiz1NcVPECxicoDXQ1p6h5yU6KozLYFhj", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QP5h7AugR5sY2U9YLHjmTTkuoZFWoomar1", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY69G6HF2SCnqEPJwwHrnBrXn6UwccfSGa", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QhPFgRDmwGdjexK1nEA2r4caPXG4SRVXCD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQrjQEssGwc6ixp9N76b42By1sFbEKDTDZ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgnzw5Drcj1LvipRbcCPS9rG1PSyXF91nn", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeLgbgD74BgbBpoPE2jJuNQN5GqyBYNRev", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVPE6V1xpZpVz2Zhu3SNkKf7TgWPAqRo4x", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY7NTMeAq2Wt9BZYf4BCwj3eJG5aMYADRu", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVj9GHBnU1T9yseTTR3j4PST8aaLGNPpm9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVVvANL8ML3RaMbF34aoxL3z1bSoznTSC5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfxrJXCBbnvGqCSztwDzrNzDaBYQK8Lejr", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZKQViyTqY2D9zQN1k6pwmKaKE1ooaf4UZ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSv8WNg5HwfU68NcGyMEJ3G9pQLGVpHwFk", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QR6QPuyzBtPFB66SheLhiUp8sqgyvrXoVs", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNPSKjSd8BdKV9y8w3CuU8str4t6AtS2aA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QT2GRgCRBJTBCWVsoxax2kNFi4eGq8DZ7Q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfNphDAZBZPDmtakni5PThJxdbi3xufDr2", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZoLPzXLePhs7VcLMGRZ9qJxCb9rzqCJmK", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiDq33pUvSHi2pEZ3cGEPVtiw1i6FzV9ai", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUNGLbMWTQELBUQN4XUkNtZQehvyZaDmAC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZtiZnjjATzg8dEoAikrbQfdjhgGcCTxsT", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgAt6xyNojsoDpJvcsUPkdmpz5TDp7gZh3", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYwUGeqXJZDrtMh6QyUT4SubuPqm3nXkYe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYdqgA8uYhcec88NxVr7wg3WReUQqGVHzn", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QhpbiBSUcTUu2Ex5pTyTS3SodSyfKmtzyx", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiTpmEJEstonzSsvuCvkmBQpf7jaNuAuq8", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSTUkD8xB9rkYNzAhZFdSAxan5Y5KqirtW", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QS5q7B665QkuJtJzNnSnPuHTeDxqAPFJzk", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfzpWw6tMMWgX76cMZvorPRLPnpxmr1j2X", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWQdAWwYPMFCRCAc2bDqjJoRx4crZVn1Vh", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QScrEuDdqGHfixHcjyHFkbg5LdeyGexbkS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSfDW3KC9P5KQxBRYf4gjJUXSf1DZQwufm", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZLFJLReUT98wGdaieoA8iLSY6e9pDtkuh", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRgC1RbtDyvka7UH6RTqSNvJD8vTNkdsNv", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfCfuAxSbNeHbF8Y2GNuFmJfmexqVH131K", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQDfTzNLz8NwmPJ1PTiL7zAtWdz7o3LQX7", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QdmfM8nzDfi6U22ze6kaEceED2sb2yYW4x", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYMpwvQHyny3zKM68SKFUPssSkoNwC5vZt", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QPbsQYN1rpJwV1GbPNJBUkCyx2YWPuLJZd", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSofobEjrtD2KntRYg5PLdFDdGuf3mdAyc", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbShL174ecJPLU8nRSjtMwbrudCjzPRqFe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgMCRwAr5JoZvth1ESUo5n3Z9ycrfhCofo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMyhFtK7iNHUe98nzEXdkN6toAa2RttST5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh7LXNX79eJoFSUtdppQtAt7Si1R1wbaJX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWvVuQKy9165r1osQM98eUnAhfe2HiFEmN", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbUzPNzbB1McDTWBDJdhpFsVUQhi1hP9NQ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QggkvYpWRuqPjcMLLGG1R9ZXJAoE83xj2U", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXS1p29dEQV1JtHj1Mv55SEWfDuHe47AX6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUfTY7fh8we4nYPVAXL2jsXSm3hRLGL3uc", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc6HfpXNWjeWQ1JsXRZScit9neymb3tsBV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe3LcdQpecf8jMiYdMcs9pG7yQiaL4v3dK", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiJC1E8sA1RVTuRXBFgqzY2zmfJ2eXMgtv", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeFinpTR23Ryh8Xh2qeX9kHnezQniEx2JT", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QevoCTEHo3PWAKMKgwjv2ziYdeWDJLXsUk", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNsPF3iZ6RExncd7NCWHzAuofRD56nhP1J", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQuFcmg7wsdHpEZjTXpbJAEmCxJaZpScfi", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QcgryWoPDdmNbJ7XXnFbhmXVpNopio26VQ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjuVB3x6CcH8k6aoQfckdTHP2thnEkeeLM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe11ExJhNtsH55zAwEuE7RuHBdWhKNHVX6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb9d7XrcJEB94Lthk1mzTfm7gMt7XjVCPr", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfA2r9SJogxx5h4Do1rMSEQuJCkeMhL37n", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUVbFEYn4SUz5eAdum1NHL9i3CvBkvdcpM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRDKcbobLECBD7yKCfzcaBAHM3DScRpccL", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVTLdnvnJiF9r5P9aEYFobjj9Urv48iyJ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhb9upVqQLzJfWGzALSVAZNwk7nnkGqctC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QPMs22gnWYuCxeq133aQ8hezvfo2ukZjBV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYfxnLX6sxv2nKaemdR3UG7AFMfwSpWktA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeSb4PYhWYzrfvDF47EL8fEQ2tj89hszet", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiGDtjHbYSvCutyDP4FwB65AaMys26bgk6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMefivZuDRcohdW6fKbMUYozLpG3Q5Q6LM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMGfUDRXUU2ZFaZDkFgRvCADqf572WvXEU", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTyeCwFefj24wSMwipWdcDNZonbCmUEExb", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSfYaHUJcrSFy6DUF4TTdhdxw48A9mRE6m", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTXbWbc63NFBBU6uT3f95htmVE5tamM5GN", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QW5yn7VbKkRLm5Aaowv3aKCja8VqqiGyCX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWPigqprwrci7LCZjuoXWkVnd195gQyBYS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa6pGoGsm6zEYLaBjV85Nhd6p7aMbCUy9A", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNNrN2VtgfdGKSQJq3Z8AXuA9iMPddif3H", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QW9HHiZhURbqJVpjuwraujZPoDCsMPdjYS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRob3sEQHX7PNW9tJEd2iaXc2LuT8MFPhe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgUCStZkxC1b8AbTSDcEMTNj2txDKedN9z", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRJceDy9e5NEGKPZ3aEsKfQfpP5e97hvkE", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZc9DBWrEADDnrnTV2DzGJvMJydgteH2YS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbgQ7mZH6JqNXno8rL89LqMoTsE7N3QKQa", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ1TnT3hF7MHhSCWLSJ8TeZXFMDZD4FY7b", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYCyEsBMT6o53RTuFtmPUTJYDFsCEQbxAZ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeYkt8Kc9zXS5s1FHGkW8iqZowABUJhgEd", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSXhmKQBB33AoZvw3K8bzQeomcpDTSV8be", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVDMMeYvQxmHeTe8Nw2of4Z6AUm86Eyn3X", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMrMeYPa1FPzQbH7F4hpsAxXGMi1cqhVwU", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QP3Vfwt5qAUW4JxBtCRbyY3qAYraLrJFcN", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc1pJ7rYLbUhTZXvdSvnD6JiKXrHHMSGa7", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNU2RHKDRs5MVueLfZ5DyZQz2V89v197Cw", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QT5rNcBcKR6uHxXkwntscbxHuUpSqAkJ2Q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTrJVPcMcisEfBBPqEiwy4UXHdQWWG59yo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMqVRK51WYgwCAXXHsVBw7zWom8LngFt5w", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfge3zy6Q1FeqKQfBB1ALqFqZfgZyWJ2Mz", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTirUpjh93fmAjZa4Ax8PxwuTxAj5uWgug", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSwkodPybgHBaerTABByNBRnBeWT7oxUgD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QaLM4cLhjYtex3JUXPzevefKhruWhL2AFU", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUSryCrEDXRwv5iKZPDdhufa9WSP7NRr2J", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbzqqkeRZtFDp1UCtsXByvkpWTVtShP8nn", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfcpK6LtUNCdTjxkh3b2JLU5HWGim9utF3", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiWFSfVYCBdTJLDNDnZSHwqKf7Wrymw4y1", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfgvJceEk3UMkQeFc5h7n2V2zhNuanGqC3", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc2gx4tsFiJSea3jYUfrGyQJWkpZfZ3FfX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjogZjZwQHrXeDsguP1AMW8o6ehcYNX1h1", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ8i9kKbWni9L1ZQf37vjfL9wdRqQYMjt4", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRyMhM2WPk2yg8GDRCHzGZzgqK6a3QXUtA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRLvMoLehvw9gK7w4HW6nUn7EGk1F83Ekv", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVqvXtRsofKyXjwXieiqEpwRrN3cykue2z", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjr86JYPa2ge6eRxvCbuorhQ7Qvf3T7fve", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfdrQRjpYvMo5FgctABoBZA1accY9GpnGo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMJnn9QY3ZwuGesxrwjQu5CdoirQ634HmM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd5aSucZtsGUpkk1A4nk6VHKHLN7SQ6bsM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNmLqbetUxdMgzvMBr5fFgVuxrMuKvdRca", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTNphesPV41FeTqzBpR7qQgz1k6WjVLkfq", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMkq9TSQbn6Tbf1dyUMmuZE7Dgk9EKi638", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeBSM5kEQdcVfA5xB2wyWX7sJiHhm1eQxj", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUUFvC2LtMGMoDoQmBjG1fVGhfauFQcg3x", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWmctuu3wzZ1ySvPANMRHtcR2WqzGDiuLM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMVtRvd3r3hRLSy1xsj8q53kE1PfqyJqJ8", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVeC4LsXkUvd67okfGXdYXHsaq91TEMzda", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQXPGZnC3BPZ5ApnQvfTZfXYaXsZZNzzxV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa9SdiUgkGU8xxCLYF9W6D4XtWpagRk2p4", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVsJzxKLR3StSb55GQEBKRLDUhWQvdu4mW", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgoqYLgYjAg9Sovw3UrwNZr1uYLbdZBKjo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QN8LhGeJiDjidBNUwrRjyXrZW282RDin9J", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QhR3ygFfHKr4MyUj2b5bBkowgCND8RqMJ9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiQwmW6cybhHYSrDfM2DYyJeCQJMJ7dzG9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg3sfP7bu2StVvDxELCZyEFMcCZ19pwSnp", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXzGnAFwuwN3uqztJ1ARPk8AkSCRKWddrY", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeLPwH4xD5CRx5wMJ3zU52P1yPw35GL95v", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QdgbvSACGz5uWTjMBcC5MBMRi6gAU4xBg7", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQgyrjSUxb1gGoG6qiteuuqfRTPVQxHw4q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbc1f6SL5AdKmg2xxTcuswEe7FP8Kv241g", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZMqoWSLEtTz3rDAiuPigkgpdwbGqFeA4Q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXkrrTfHCdqhHodX5ZYmR4pZ99bykeFqKe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjFKeSTEdusbqF2S4xFQURKsHMy6m6QjbK", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY9U7czTSvqgi77fRhuuwmVrWBZYxCqzQ2", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXAyhHovPqEDmdUgRtjnrC6UZMVWE9P9qS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUJxJyYzxgukWBNc4Aghs67DaWoN5UFFn5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QdpYgDsun4TwoNjz1ZDsyed7GEGXchNw8f", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZeYrnArdbNUW8bgLxaJuWRyXRmrueor9a", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYYocaRuwxoZzv1JWr8egZkGZVgNAkd7o5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNT3JwAF2cQ3CUCfX52x4WFGgksH4731wA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QLsdr3KVacCYGuufGkyNerzHgCyNS9EBiw", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3mCSgWqMECNaSWUDnXbz3a6sQ5SRdXb9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYjxtRJXiDHRaP3urEd8MX5nUV9fbgb8Gq", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjgNuzEGvBEotvHo7xynD3h31mntp7PnSs", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ6Ur9DWGZVzzppkWcZupGAbU6jND8mN2A", "level": 2 }, + + { "type": "ACCOUNT_LEVEL", "target": "QZsDt43LLsYoif7KSHmyUXcUxhWgQfz51E", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qezvnvta62kW8ZNdiio3h3Eded7sDG89ao", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcBuTqxNmsg3QotEnW8ZCf1EyWHwqBc3w5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QT94EE7rzSgazh15xpzhjhuqKFE88cHHgY", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcBqZ4ozxs6JPcvCT3beYzki5Na8pwiEPt", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZo9xY1NqYwr8XxoiNBVHicHsQDRPDvanM", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QU6DVbLkztW8oS1Q17j8QEcxisSbxnTZzf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgqdeTtYTKnLAoCH5x3mh8EL4bRixSAoB5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhyRtUUohmkbDzSjZw422cLeXBUBK1Rygw", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QecZdcfkyFbKqTXGn8i5s1iG7Rfz6mAtAS", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeVSYP9juB5gfwL9QMz3NgYgNj1FLJ9u2x", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QXVoRnk8DKFU6AjqPAcx3RwDnzDnknxwf5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qcc3iyh3ektfySjbxgJbQ2g457k7KdF2hH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbLxPwNiMmdaRPYywjuMeu98RDAYaZPXQp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi2DAZBWbia4KeE52Qt1PVvzuSEAHQAmyh", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgFyAZ2mUp1879ZNpKb8zHFCsYDnHhVCmR", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QawWJdQGTNHk9VQUwF617GRCBpk2zL3Q7m", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiYaG6TMPjtqQwFz1KeWp2ZX86JCJtaDcp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNvmjT2ZpBSL66SqSEUPPmPK7pddcxauub", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMJshnWZZsr7NRTuJuwHY24UKMHkGorRqU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgu8a7dGNaMLudiF7LAKGA33BSzEa3Jdwm", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbrtqmUoEDdLiwnCWtvNwXaccaSpCKo8uS", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQPwgGCF3Bp28VBiDWFku42wDYpf1sMxQe", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd2iY8utUL8wcshE5MCfBR9SVBmKbyHU4F", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QamEfYzNmdo1BEzSbfQSqqSrHbA9AJCaeW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcQ6gNFN3b8uHEhCuG9sSgk9LeXjaHKF8f", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNtqkq5KtkKKF1jYQ3GaNFHALANh1gZ1Qt", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaWzzJ5XGtKefyCvZ4wCMW56JnJpL8XWYs", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfeFfrbAL1pxC5jZSUum1BYnbToo4u5EhW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaRd3tTjcroAPYXvYR8zmojcXPHL9DZxd5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeq1BV4i6gN69DmQ9AgkaPmizo17YuGKA6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhcc4R9wJ6mbxB8jCgA7gxsonqGaex7hq1", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfSa6ivpmWjcTZKw5Mz7sLKX4S6NgPFrFU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe3LntnKWfJLkkVcJRqkRqSzqjcJZLrCoa", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS5P7zuFKFineYRY4Wej2USv2A38GVDbZv", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbB1tCEKriy5wRnEVetWZmByjYLUyFkg4g", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QXBXg6c1jNYZ9PeAKGLsBiuMY9MVyYVNgz", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfp2xKR2hiWS29oy8GYJgRANCQyHsSzXMf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj8yVKdxvUxBe4E9TvvKcjZ2UxUpa68ZP1", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZTGHpZ5cyqGBBpiHMTPSGngmqgmh5LB2b", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QR5SGcLtFAxk6mAQZiAMMRUyZLovDaQnQf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcTPM3qZFXsArex2Tcjq8KzJmZeTL6LG6A", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi5o9RHLN8menSyT9ATAv3A8ge3vu94KGM", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNNRCNBotwc4Z4dyYTwhdCz28EBPHUqgng", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ5xduv5rwt4f54jicU5KB6TkNZQJZDgRp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQrvmCEHwQR5dzvLTxy8edXBzHJ9Uwde1W", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbi3FU8dMLEZHJT7DdZWu5rpXnWT2GTGF9", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi5PGoa9H5zBmfva62SgbyJ5bo2qYo2uKG", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUpM5bugMgiZ4AqDiT4aiy6mLJQ7Y9GeRU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQJJE79CuahqQHSJ4xVVcxANHfE1YHMUoi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaddFd123JhgyyZo4SqDzRxkD4v7wyfDxu", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbuBHgF86E1WHKtiGswiGpWZxtFRg7L7z4", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdDqQ6rBJLrW1DhPuQnw2Nh2pLbHoXB38k", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNtb3aiA92Gd9egNvhK7a7uwZY1tDHVSCy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPV8fxpCPPqv972Pn77hR735rQ1h6dzAue", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb2ampydMe4iTvTfh7jtuUbcAuH1xJUpHm", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWEGkXDJvwyjHppad4JVvCa6jvttn7aPJN", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPHTv62t8XcdkRjnzU6qmN3yqi95o4F4An", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QY5NwDSwvBFNhu7M2WxUDvyvDPmExQXryz", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiA6aEE1mq9PPkNTAU55crqkuHycdS1Kf3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeabFZdH5srqgfjN9rACGbqkSLdnPHc5Ym", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhKHv48KUL4spnjx8JppAdraah368VHa3D", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QRC7iB3Ce2vwSfFexT2gipP5VfFBkzYG3K", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaHF7gJzo1i4yqFqp85QxoQ7WGRuzhm9kL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNxE3CV5AMfqgpKUrLWPYkVjWeJj8FGvZL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbBvBr2gheZkKiR1nJNfzhA17rnFpPeiXr", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUe1jYckxbSTYddnQDqa93xJh1Q13pbgwi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaKWS4aJHWPee1mGLK4NKfYsHoLym4qcT3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWU1HEMTbvMKMgjVmRN91ooaAi2TX45XzQ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQxVZ98CxWA79KWer8tBtgbbZ5vbdRfTuu", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNDHBHKpVz4Lr3EBDkSJ4ZoiSxG34VjTMH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QY4E9pEXcEFH3Eh8KL4vuXZZEQMsCRjJLw", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QSdyWsbqYkFupwWdxt9AbiQoP4cq9ymPgX", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhR7nJGFMV9bhj34Ldb9SYiTLMiJWnA2N2", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVCFSYhWMLTCmPj6mDnLq8JQ9fDTaPDCDY", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVp2aMAFAjcFQe7Mev2XrxsTCYUcbGfsZx", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTred1oVKR9QSeuzZ6BudnkK4EUsojwHsb", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS51FHhmDJHrJ5jxDVTPXbxe27hoU3aJ7k", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QerB77uXd93h64KuMXT1TGuDinYGyBz7Vp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUBx7ioCLLbFuMdYCtxmxLpiG4EoBCtxDF", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QYexbcwSivr8tvr8K7P5vkWV6wU2Up211G", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVY6jD33ykTCVLjwaL3bnuUupzSVKnLVyc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTDSpCk1BwrfUhFrnJb5jo4u5ce9mYrQtq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWKFaTrMDsBrWB2fbD2GZ5j2y8mt9ofmqN", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj4Y13T7YnRRnZoDEQcSvPHgDz6dHPFzUH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMr2cySkP9ACj9T3pzhSZkPsCeasiLTuuF", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPocRpr4MzdpHRfjXDdp1PAbjDVBKMCEax", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QP48VJk4UK3XSafgV6b3dLmsJfnDvNX5pD", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS8SYFNeDzyiNRL4tJBLQauGMBXkATgFHE", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdfAyJ2fGxnzmyXR3J5ekG1LbjD2nhUJZf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qah57SitxbUZDeAiCFj26k4hvNFjX5cQSJ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfRZsM88kdbi8a26SmrZdusR4pVTCLCHmd", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgtS5U8K89Ax2mmc2JKCWBHQNVZ7tLwCJn", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVgD467m4gCe8y25X14xsMchFvzbeNMay3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWScc4dvcbgPmAQSAUZsfpqCRPE3nivGsU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUBhMg3yy4FtiW6h5136CfQqqHDxr3SUtg", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj54QTsNtZ2HtTt7tPaKMVZdSQtfdNbrGL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVX7DzY5oCJydHWdmEuZMpuAFLpVYwmHzK", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QLi4GUiww4bKQH6ouEFFEmyHMXgPDvtko1", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiEUfeoo8eAKUgFad1qsMziJWw6ZenUxMd", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMGeYbe4aXs6CTnstGib3zZd7k6UvTvZsr", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWeYhaXNW94WAY1YPm83pXaZfak46AWaKe", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZMUskZQiycMLrcCmRAE1xDDLCyTCAVZrf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfVRtTXq5ft8L8CA6XpUKYK7v1Zea8WJvi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeWSpRvWQ5fW4Deac9fhy2KogSYJzrFyKf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTnFhyTywaTZcxQsHuKYXoT4x5DMJ6zM7u", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QepQfSL7yZQAKFxsbpnqxiWc12FnrC7jtv", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNSGkxLdJwqztSbHRP1a9FV1o48YkYAgGy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgnt93E7MQRUmisXR8anK81D9SdmCxBVob", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbfLajkHMLZxNTcK2p5B5AKJhVbWSYohog", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qce4cfhZcTbV6FyAfGfzwpP58qpeDF1Cci", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ8wsBG98Q7HxCwvCUUVfdXo9CX3PcEpTX", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa6dNeXGkMdooTd8SxFicZYxbxPGCwLx8s", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMU5izcUpRNk8CRzy7VL6CuP1DS4XYnNeP", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QibWhLgP23xahRe4cDQ8JmdSavEA2RAbH9", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWn5gL9aBWNArVF4e4MRgP8YkUKee39W2y", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfgiHy7s2jPJFe6zvHQTcZWwV8ojLyKvrs", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQvXhppxwTDQrhs58Gb51BM3aUrFevPH5j", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbNB37Qtoh2i8Pj6MtzGANVUZerzG3Zb2N", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QabS4XAyJpXzPHZyiuUhurnpuHZpACNuny", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMkuFWrxs2Rhwt9KuhVghX3CAhcSXTmN7W", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QSrPzymXJwqDbDpmEi14pRjtrrdehZpGyc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QexNnLLCdxJjci43j1FytfzoaDD5RmvoXE", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMf8KFSxAsyTdGrNQnFdXQkE2fcQrrVWQ2", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVfMdKX45x5FdnRTdKAURPCkymYJRyJgRX", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdo67QsSrDhf4oL8D5jC2efGgUknAKrEWK", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qcb5niZq3fapBq6YHcSFmpPdK7zKAyVqMo", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QSwnihvcSfvePUZJUKZPuTr2WQb5BiyW1D", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgjEEYEAPWSwS4jEVZcYdJSvLfwoywCyvZ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbyC9ue1BEDbSafF4u9EuhuBvpMvm8rTAq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUKzxTDD9AzthoukedkqSYDEHRTFjRFnfm", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhTn6DXvAatHbqcz32NJ6Am8nyNDc2ZMQM", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTZHY3vX6aLVGWe8A9QjW3uHMGhd1pMAPa", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUmfeSj9Ae9NsESzHKsgyF5i7sw3riWbCJ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTc149rVuzoJ2kLLDi6TLQ5QQfU45B4VFk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfX1sfG3Z1ix5mm2mdVDkEr7fTnq5HYRCW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfMeaYEra3ZP4576eWgBYwyHX9gbRcHE8x", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg65672zycKy7Tb5SZYxXPNBvh3vPwdKdy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVF8uodXcVdnvU7DbRg4FJBR6dfYNK1vSa", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiA92XRA1Sf28iAsrQvZNsYTDJpUAsyZCc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfEuD1CtcefSu7jMYpwDhZHupBwmhaCTsz", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QacH68BmZyMkB8dufjzTjGWMYkvUHUwFut", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb1KRviQaL1j93c7CWb36KS6pvfQdUSLzk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbUuHMMxwbbnRZZCNRTcSK4gZ5fSha55Ed", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdGQppMtF7LKj5uNBCQU5LQzvwiwXeP9Uy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVt2cpiE8HLsofz1iEyFcrJg9g7MbGQZTk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPc8yZZztKDmF8SCKBKHcMVEXqyWypmaoU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbjPhMwdXdkFY1sPrE7jMWeWBcSRTZweN6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgc3Q1ZRWc1LKX51GqPtaYzXyLn6SyoobB", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS3AQ7DD1RZ6M81XcGdrKibhNgvKFnNwjY", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdGfF78c8kwmGFD7DWhtZMGvxG35nT7tZW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVdh3tLLkAZdYKPAMz4CGaSqp7RvRmE1wc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QRqjcSqiGWADnD8Z6cF2949PYWwRAsdWd5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVfxu5mgUkfiUjdwxvnxBJEsZuUaL2nM6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQR6WaNVF8y72Extb6Ndb6bqEDabCUiXs3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhim4KT9VkcxbE6a61ECZ4nq5dHUtb9okx", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh5GLNHyNt9Zx4umjoBkbsaPViJ8xKiVDi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QV1FEkzd4nsDZPddG3sWBdxWCELMkZ6HFk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfrwyMsGvF9Vo6SMyPyKSuveEFikx5fgvc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ2rRvcqCr6nj5kkxwBDT9ZTfN2akMGv1z", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QepQTxuer8cnS69aYf7EDAoWQw6GMPLibW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbdB5TN8P2mDRrBi7kXWu6U8vNkMyh7RJ6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QjEsV1pxjcHPNPV8m3oCC163w6t9PZZF6p", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc7yMoMRrA2fXmQ77JuxSfVdXyfPdcnNwp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUbAAYiv8P1oACxGDp4jGWD66t7siiqTtp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQsYuc5XBWaUsoR2QAVs4AVKBmY9FCSrQ4", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QXo7EkKEDCE1SeReojKyqVUFVQ1sriN1WH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaAo87qyhKXU26y1YR1FTvrLHv2uav8KsE", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNJT34EthEvwgonu2vUVHNmosGRRxZhSHh", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhTQDuBcjfnhqHu8mfRJAYn6VyFj7YjrHP", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQpoQWccsb6UVWnstdZzyMZZjBuWLxSgaV", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWQ6MUMNQKiJFnF2iKsFakeVvH4TBogxki", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QRXZA3wdXpu8phg8KJFe7RNQhs8D2P3DLq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd5WLbqdcUcbi5ZyY1rsDTBpBG7X6YAS9r", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QYAk7XFu2bG5cKTVApjey95YRtie4Ed13N", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbPqWcpXcNGuGhYZ2hvLNQ6XhyfudvCbi6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qajx9YjYHukNF2fxq2UbGniGdpQL6jzy5t", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiPKz6cwzu6HiWU7ayBjPhW9i63f93K2Gk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPmCwzYeToHypmcosysxkSu2hnzEPkZ3Kq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfFs5G1TPzDzsa4UUB5PmypRnEFTyS3674", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QT5Te5Ya15tV3vSmdy2pPpZdrnztAAZeUL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeiMYN7pcPJY5GUvZo2tYMHvDvRYx1cNak", "level": 3 }, + + { "type": "ACCOUNT_LEVEL", "target": "QY4NuorvFU9AUhonC5owihgNdRork8oo1E", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSDWB7bKAoH5sHRVsUNmTPe9xDkvX2phom", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNBQQ29UH2SA97MLDdnTy7ExxZxLLpfZwU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QhP12VMSCpC4PcV55Fx4aFfT2c6RSsMs42", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgS64v2deiY1Z1AiLkrRxQKzJSMCNXVgrD", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ2vjwzV4Y5JCGzkwJPDgWysNMB6rFVgrK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWmWNDLYdKkDwy5kRbyRe654wksS8r2nUX", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcwaxY5RRmoa3fSntzJXZLrwLmfrjqtFNu", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaoJPWKxmGRq4rWNfo4232yVX5WPBuoKqC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSJHRs8N3dbPwYbhbj1L8jFWBzrq7L3duY", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWRzfuzym3kfZzuoA5ASpnEvmgeHE18hF6", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQUUAxnYkmMPs9WWQcgjwUMVPGpKnQPeYc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYqZN2qwT4zfE8XkAfTnvpQV4ws3JbCMxU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSx29CwBhQsJbQ9hVQoAFEXQR2VYz7KjMK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRnJfCXxGrdEUDVdHDCw9DDV3gKgRu5vVQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgWKwKe9mZLgTu2NeyeDsfuPVE9Ku4Zt2s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qcj8jG5E9KtEYK12hVmWdo6cdUKven9z7f", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUMJtgeL4xEBWBT4NZdjqvMWGyfdagQ2pB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdPxohH7LJTdUSXXnTb99qhuMSqJFCxc3s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbW5AkBDfr1cLZHtMFANoMKB9ta86CAYD1", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPgKTyPyj8DMv2nLZumJYYYwSD7iF3Lw3U", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QLzWPFyLvezHjzdwnNR5n1jUHpHjdjQ3R7", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgcf647FFAFZ1JP7bEv4sa5rw4qr54uTQW", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjuQLSjRyDTVhDgMxzUjLJFbnYdUeXyH23", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbwcDH8PDbr5Kyr5jwBZ9Ys7hzg1A5QpMA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQfhecXaev1FYq2UgMhpzZa4oayc9k1nnQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfpiA1rowLYMDVPf6oe7E9R7WNGQwAKfir", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQCcSHJspqeYhfxbK8UH1UhjHeGzmnQEHQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPUtaNb6ANbWHLJCGMs1o74yeb6pYmHNcG", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYrLYW646AtMjd6Nn3e4qzeKkMCzhtBkG9", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNfyujwtGFucVnjkaDEhdRprnixYfV2wz8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSFxD72vCMra8P9ohh895NuuPHvof9b7qc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbVAUHJsqRY9JNn9aBV8VEowQQ2BbP9uyv", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXtM9SWWqqJGS36qDq6S4MnMt3dnUb4kNp", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQCr9Aj6XtEVQXbz4D9fHSDDwn8ANbQd7B", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSqyNKEktA4iXb7cWWTSUMkkc58vCiCJCH", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeek2544Smo4zkMHvbQ2tVJhKv1gDAp1if", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQUm5WQs1jzN19X7Ls9NY5q9G1BmtbKK3U", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QenuGzurgCPaeh9xDxRwoPRjNivgX6h68s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QihN1use3mN5BshhSrSS3hF1iMmwPFcdog", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbu1Si8WLZeXpwiHXzPsSkdBMDV1BFLkEU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRkubxXBe8ABtsWFpdB498EhBy16FNiPMo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVa9VbF2aGbXNh3LfNxnFJ9p8cqSmFnymi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYqQHwpJMPR8aa4PWKEXxmc8uB2AybcRt5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPHyxSLBx92izt17oifcsBqh2WYDTWcgpo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg3QXvRPXayBGqsGzfvS1a1Eh1WDHowBLM", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjJu5DiC3xVkFca2wznFCei4HBbvCRPoJS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbcDt7uDJok9ka4FaVtXaT7LYR1sQMENyL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPR3NwdDuZuZGXW1UZjoZhHKWreLw7iZVi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaoWao3UJjZpwbwj6YgrdWgS1dDvR2vEFK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVX7YLBES6rJGtTeLespEspCxi9oDYxGQ4", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRpsUCr13shNTDo78B3r4UthkXa3E5FgFr", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QREUmhn4Pty6mjnJxJH7RxnrwN1RvbD7fe", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPdzGiWdyHjbBhtCMvpd6QacfozzMPpfNa", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMriaRPNU6RZJmipSuZcRi1WVj63wb6GrL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QiW6Kd22LCJjCpp5EBotFDeCKjCC7t8vSY", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QN5GSskzBjKQ7ZnwMMqgko1M3KWKCVqwh8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWwra8uA9M4pvabK41561mgFd2o79thQdT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcQSX8HPRpNDGgQH41U1QV5FJ1TgK9q5Fr", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcTrX5Qzbe2djro29T3wKDq9MA8m86HqUH", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWpLWkuZ2pMiiPXRM4jupQe3vBp7GiRtvA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdLHqfBmfKG7mnXCUALfgQvKW4S5igrDSV", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTBiyoSuy3ZF4yLJzjqUY2imVPsUULFbG1", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTEFHfLoXohdbT1FFdHVJeEL22qmMypTJ7", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdRwRvbTfT43sHjyG7q4f38PaGvwiDyrWj", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QijECkb9URmgXD1oAtvYEe59dPmU4A4fHP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeh73yn3ngvB5yX3beKJArFuCJks5i5r7B", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNWzHrRGoZtYhEUznjdxCmMi22LqGa1ndN", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgNjGNiAKViM1Mzd9pGHieNJk6CRSFrZKt", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVrGMZ8NBLEvEcWXfKkCWXDvUCWF4z4yXC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe3camytysJJ2BnfqGmw7BUegZXJTvkeeJ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfVUkFvVNPxQCKgRFWzFQVk6oFCbuyzyRW", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QP86kJ98hxBi6rzAJFkoCuwkQXh3DvAGcw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXxMKghXxEKx8RopT2rdiCrBFvoyN1mZMS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdtUw5FRmaKAfJ1Ttu4bUagfX13cTHCpFw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXuHgMUN9FWFVFjbquFqkDw5NKRToVd2t8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjSZxZZJ2MRB3118i1VmSuzamBJNCnUFaR", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUWURY2qbSM29to4uuZ1CQXh2VgWp5AJsT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXbg7o4ufCFjeA5uSWDSMB28vAc9XeRSH3", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdmf9fZDUFWxjXZU2hrhTZmKiiMuy6AEyC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWJgQDygXYBXuweFXPTzte1eDMg3CnRUxS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgZJ17ZXdrJEqcAPM4Bnj3NJimpCupDs2x", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QiZRd7wFASi2jaQfiWSMFS8Qcfrp3MCDxo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZuxbXpcWbPNHoU4yEps383E4rTKkkTdBH", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUigfC2QABH3RMuStStggx9YiZ49VdtWTw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVQepwPCzSosioi85mzfCxVMPR3f8mGBjP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWBTMtHSCRrHTabkzf38tqhe5xSB8wtTN4", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRY5mhkq1fV9MZ8rtrR1j3MnCidqfstKCX", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTnnKbBSqDGHKiD1Qo7yb8ry33mzxZDs4E", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTtyKRb2fSeuhH44cvenzahXSKWiGfV5K5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSbg4JJCm9oZkESD9obePZpGK49gWbmGsc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRtARSe5ppL7WpNaMaWeboWbVcL4Ua3nxo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaQs8c2ccbjPGhpAYdaJRLGyBTWTkDKfmh", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRVotWtKboC5APg8YxhjdJuV9JioWFydiC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjXB8Mac8ityiVMWHkXPbi7qgKMuCjKdbW", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXLtbT3ru6WfPjTMZ35q2f29kuNh5v3X8s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QisDJUYKUnv4sEW3RfVhNywxVWcHFg21Rq", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVpbg3oMF4MAUD8QVQbSfK49YfUYAijEPf", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVYPrrnPsn3D3AbiXsCk6wb3EERhTQbauT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdjJKpJt95jGgyQK3HR4qTYQuwAYdYTM5X", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcUths9zcKzhmWxpQjdoPkf7ZCrvPqqHum", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfErd2q9pvzPGuoH1NRSUgXxZtz2oyWX6C", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg8zWrXTiAk2r1gFLrh8e2vSen7DdbYU6X", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNMF4gGKyQxKBHxC9weivsiGwJ8JFAswgi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWNik2tj86KQh5zGCoskz4Rhcd9K1Qv2gL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdxqwnjHC2qy1j11YMZP9KF9dm8AbyGMby", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVYtX9qPuCxejLZtUxabTJ4urKFMcbwsb", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj6ykh2hXy5jiYRgrmt7D4H2KvMX5oPWac", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNyT6h7qK1zS6GeqXfoMJUf15pyush94yg", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdnRL1yDRGhoE2695SCLpPdCzzp5xZLMDc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QX8FkJYYLTjXSwdJBAwHtTvHiZWCmAVmCY", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXqHgue5R4qJNvPEsxZvbYMpsCRmD7YmRf", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTUArhyERXNN76q33wdcxJzVxZoo4YUQk8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZHKbDYdSjS6FF3Mz41xowpjHF3fh6BvFb", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf73yQjcdH2hFndu5f7xcb6NDt19TP9DoB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMxU2uvzikxgzj53sE7cTe6iri94z4FuXv", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QX2gQw3y5xuKD3shthn4cQ2mZ8b6XLysjk", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNtoFtG7gBXeocAGwDVvC2JX81qs6gSPHU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUHZiok3byKWVjdp1U1LcVhsqcF5ARHT1q", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMZ7Vnq9P9TcB6LK4WLZstuf7ozSUBJSTQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPy3imHu9bFEkNPF28vidDQTZtLGdgpWqC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWMoMkXwPv6f5s8PxRfiy6u3nYfVMpyGve", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRYAgyuRWrLVg7VaB87vBVWm7kUyYFJ71w", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QX6PwuE3VRToyWd1Y5jiUsByFvppDeha2U", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfoPk8G5fiBXJ2S4Yk8PpcktDj8AZnShvT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdihq99B7ZnAtqru71PAGQhjhjtAJdAX5k", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTGMkcxHVmxv1JkDw8DSWtdB19hTJLG7zd", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXgeaEWieLL93jvT6QigYr9JGcJdnFXByP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSSKJUY33kdbz1vkiEooiY45VKiZkD2Dka", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg7TbPrg1q2ydVdNLqJoQtC3RLBUo3t2uD", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgcVr77TfcVmb9iSgsSRPeQqejqfKAvgQy", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYs3NV1EsGc2GaBYC1jPAPBsZRGfYfwopn", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjDgJmv1gnz3VSJHziY3quBHE51qEAj9b2", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMoUYGYfYXdVUxAGvxkisHfgzwvf1psMLB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNTdKEnSyyUFfp9SPnbUSgbbnHi73LZ4py", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf4NBSfFKmjQBuuL3ti4xUFLt9cutKrHDw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfjrwPMPQTdkc7rdu1qyUGGmy8uyXB5BH3", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPxbzCsTxCsafRUWp1oBHfbqRva6sHyxTk", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSKnXn18fM83HS4J96BvSHmYi5CfvZYgWn", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QN2yfuEHpZqZDZREXUUTp7JDzzAnzD26S5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjCL939qc9yuNuP7KvEnpX3Ykj6vWtU5Xi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMkmZBSS6CqiUZrL6HkgL6NeEAbz4VYgKt", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVRxJWg5jsbNcuMFSzEbrQ6ZCWL9qqiQBc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSKeBHV5ndQNEwZf7BMT8YMY63wJPXHUDg", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QM6nHqVPpe9eXvpeshH3fzKS7vok9ykN2c", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ5HVfecMcnxnUbxhNPk1HV2GMUTqUF5uB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNDN31DNB3cu6E6hKT7YQRv5P5wzbvm8gR", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaDVaEtetDZk2SQUcEwrv7srTKVi5nKMXk", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcnYqcsiJ5bJnyKGMHRQA3LjB8EP6kbRxs", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVg5c7fQ7AjQx3Vtf1esfbNeMjuJ7HSxnS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMG7Q4CQfS8uWRFVNZCkMq9EMeKQMyo8hA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWeVdu8Q6UtPCA7oxcw1vN8V4BYJ2UTLuT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZNRfr4Q2M3GCgUiCrffn4rr1fcNLMLuDo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXhvKsfnptbBhkbyThihqZyU9QESPfATbP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeho5fUhWEb58qFoZdMB9LggSnbaQh9vRs", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe8DdBX1a6dzMyX6kA7BXHmJz3hPWB1y7X", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSAejnaEvm4pSS8oXEh3b9XYqmKuHhqLVb", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhidz4HLjm1kVrLTd8EPyJEELnoFVqnATQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRtmedUHNdfwaNwBWX8tAK88mwTWa2z8Fe", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQa4sXFp5jq7ntE25pvz7xVU3rUWs4eiiQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSz9ksffuikfBjBwFRQJW51wQ5CbSc8HUV", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVHXvLjCrNXgcRu5nw2KFNsaZ4SUkcod64", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWnXShDuAWiCmmKsLtRUWrbovhoXafU5sP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3PMPgJbk5nYi9wZxpyoNwNPaPAjBfKa5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbG8iHnYMCEt5Gv4gbXT2sTiafwnwy6SWh", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTkzizg6HmDCNjA2XoUSJK79dgYTAFdNEg", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfmqayQUFTY8YTqrw8odoQ8P3RwyWo777F", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWNkvkThhZXJQ23AidJ26bUQXiNCNfeNMv", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWmJ1XpdQSmZLG4vDS8EoB5w6UrwYzUFNC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQT6fRGwyxAS1uuayVJvetBHBhdKpBvgFt", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWbqaN8TxsvxDihfkUUBRobujVbxsbzoTA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdtRTY7sfe2xQKR4jFRWMpFyyP4EoPnvDJ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgyC28vh1ri8U1UCkjwQuCinjJS6xmLE11", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QLnMwxfVJf82MovsG4i5GPvkny5JNBTQup", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYF6MLarj9k1VPKyog2YHDBboeFngmUnTK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QeXgozDEv5NhxmNzbV1HEugcceLoye2b2y", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QLrGfcLXyTWmA8CPUZkPM3WywzTAHVuz7x", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcfiV9f1vUbBLrTRPLJsyhVgKzKT7uuHKi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgxgpn2J8c5LzC2aUkPqixnVkRmd4fjBUm", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUmr9efCkGUt1qMNer2vt1xtcy8S9wTtAL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QienqdWpCiDvk5q99F8pt1JYTZsSn6qLrD", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUMip9ykZ66AP3Gbg8pGP1ewoZwoTZBtba", "level": 4 }, + + { "type": "ACCOUNT_LEVEL", "target": "QgYWsGqKjL7MrJdQmsHXMhtKxJqW6vWyTw", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQMiJaCGrw57PsF4hWmqtnbmyWVLPkq1s7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPSfVkCtF3NJYyhPNN8yNAQY8pgRbFirgW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNrY4iAmR4TQtB77hMrM3u2XXYX2st3wxD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgCQoTy5Y5RNrBjeycXthX7t5HX7oEzz19", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiczLg5bJZsut7zqwka8E7y9Hi6qPh4Jqv", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQW5QPRbWBFQpdPa9x9x8AxejhgSTUGTwJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTEDEPGWU1pED5VMo6dPYrN9a7CQe1zWtS", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZDN32a1tDV1mZ2jMZekaHiQq8QTfoaJ6a", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRBJVtRZGb99SosM9y5YJ7ogsMdVxXdPu9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QifaDahrcETU3Jc5HEQJVUQdSXVvRYXUTi", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaXsvTkVCcfXYBod3LLnT4yBbVyxAcSK6V", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJq4Q8ie25z7QdfzeXoSJYqkG4pYQDU6J", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdUDcf7Ey61TxGtfdW8BLTZjBJ7zKGgk9s", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgwhdGRUuSKm4xqpT61xB5iiP29wKFkTXr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSftyUsD8B3F5nkW2YjEikmcUvLoGHjUL1", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QP7NckFrHLgGKbM8aNYwbGCk4YjsmgeKT2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYwvyiFoaoK74dw54N2xt7UmWH7hwUeCzb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdssSgnCVg8M66bacZqFaYCDXRGCpb3ze9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQTrDiZhETEoAimcJFfFT63rzqBy6RNA34", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfi42mfbpxRE2KnqH4TGQzX5dEuSGaTABT", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRYgWAEXBJ21AN8ncvWYN1NhQm4iQV1n6m", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYu45h5kp5TAx5R53mMk7XUE1YgkEym2H8", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVX3JsK9vcyLBLjoWY4WwDLF3MoL3tSDMk", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMHcurZGnyzPAmdNurcacm1GNCUHRRZ8jf", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVkpbCGEwfdkEjkkXPjZJGGqPG4F5YxoD7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3Fdad5SPJuMFFAmndBRe9AGumWxvJmZr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRJjhxgFEepMD1Mb3Bzgmd1t2WuSRKxrge", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QifSzfbmba5KHi2HyUwdm5C9evXqXENgZk", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbs49mBzkHtoKouBUAD9atYUz6RBH9pQ2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcBREkh5tkffuFLT78SLPJZCzVWXhnweoR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbPcRUYCLY1WssipaRygKeEBm1LHBPZnuR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QfypvmTWiTHo2GgpBA9CrGvr9ke5Pi1dvG", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUEviSVKeHCwfBm2cpVHzx5aV4uETjARNh", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdhGpQJJDuVbZrVdNUS2ec3gNq2D4Tu9pF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZEKMgog8epbcSKGaH3stvFX6mc6EH611J", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qiq3xFZgSv8hiTmMs2inxf5T5tDfarPU4x", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZSS1LoXwHpPNzgW6schoQgUNoKoCA3iVF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXaaXiBDAiL5nwVsPhGwabjoEaV11q3DzG", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSUBoFU5hhcHduACBiz7kD4UAf8jo8zsTb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qani4X4UGeXamvzHc8X4RXzA8jWSmH8cAW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSTzv5G8YEhtHpGoUDNFVW9LMNke77kLye", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNneGgUVdTAMkQ9hoY1XbezGZ4joa3Thnp", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcNxuwspRac1sGRjotUTZrsNAX5rYr9fM1", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTuZoN8Rcm4pLUNP6HXR1t3tU9Z4Jwiu2T", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdvieSs2Lnr8j76TMaZVwiN26kTzsF7mFD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdSppgA8ZA4ojEPdNNj9akBbgDPvnTQH8A", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgSLWX4QEgujL8vB1btx2feZa7Nyueyv8k", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSoV8SFqxoEweZ1rJSsWtM5wJJnrbT2LGH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPpr5vJcjoJY8f7Wv4wrQMAyfPx4eB9Kk6", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QjHyEgbcJaYmmABWCMTcDiQAHsmYZ2ZkMQ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QadBi4yLoKjC6XHKGmrVJsVZReR7PzHgmo", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcAkNCK6bF9sjZYsroSAwRRygVrq5Lqjbg", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb2vVR5Jq6AmCDXhZutKdLU6fKi8weGPzT", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcgtjvYfx4BnV1mmA5FXWtXavXWkqJaYXo", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTK2wbbs3LzideTS2UpXLwKduVaFg3aZ6h", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhRPSTf9z5nELnH5otVyyRN2iHLJGM5gxH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPqdmoZnmPKCwugnbtURKgVMv4LCN51Mra", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhz2Enzj62kVerEnscLa1oCmJXYRk1b9rY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QY37v4nnj2JdnwxvZRyQKok89PkXNy2DRG", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXZrPLnA2yjCrbFkk1TJ4rGVunXDTcUCiH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWae72VAzeus4aVbUYJmtqgPhAYvEWrrAD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRzCnx48eJtqm6gUKhdSEZPVE4PoD9cezH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYFZoo4jcSDgrPxQ7rc8FhPb8fcNgBvrhu", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbh5t9UiQGeR12cTMaEreo8pBQCEUodwm", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPiMqnsBRRwGQFJgzNK51siFqUGppfH1cY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QURar6EGNcaXr6TZf2X3gHCMkGBhGQLBZN", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgHdmfvGfiGx5kSn3GdRn72pWafGey6Jia", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbRktBVQovHF9Cc59M98VedTAFwgqg3jHJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QU8mm9JDdtgHpwWEA6Snou1qvBgwVqQjio", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRPVT5h6VuNWyXWhtL1nMMTi7bGmw3yMDX", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVbmnrrGqe9RjwX4EHU7w17AY2mUrJ9y37", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgBLAB1HtEU8nuuSEso6413ir8bv7y9NNY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QV7LARZvy2Psz5kLfsD52uEeQwHuM4VYtn", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbEJfhEUV4nqBeGsDUYiiJyHW2a7LHzX1q", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLeJ8R9FKeyhVivaLuvTt3vcszKDxEBWXk", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWazYjG28fWUvGCoxvVCwhz47hty7VgHdt", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QP15D6KGREk1eGZ8Pjb3LP8jw9oaywHRCH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVQDZsQSvsd3BFiAS45WjA6gquH7mKp3s", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qee9GheZLwpyYPEbGci65Cu9ywmfUpiUtA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVYGSLnspzWNtGCDBxtF26JMY1PRR9E8Mr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QjQCea9aLKuXQNH6iqADfC7yngwVTdA5A2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXZJY8MgKXviC4xeuMoZ6zaYSm7dJqZYaA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfv2jWMD3EC6sAGxKx8hRBSQVAt4YmtTvX", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVzQVQCzL3AC8bAHGkbTCiy3xgeWGfsfR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd31D4nhiCMnPFHoKdjeszqbNP914JZ8ro", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QfgfpEia92nL94vxwRiSJp7ee5ZophKhJ9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQdJscTibkMvWkbZitYzrWLtnTxhgt7K7U", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QR5WMxJWgaBPUiDhyYvbgYfiNXMjvqg6UA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJKN4HYcMBw1BCxEPB79peKqyBE2o8pfz", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQBRiZmS55ZEJNPj1VQBCQC7FVvafVdRBF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVyrsn9hn3evAQFm8ECjhRYeAqhDZgwiz7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaYtyv8etaQF7gQP2YKzeLqKyzrp8jrJpv", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QStvmZNCzqNeyfzzeKrq5xQh83P1F6ERpt", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe6XX4Eghqm3psn3jSzwcsJ8N9yaaE6qXJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhy3Zg5D95QWrprgRyWL1Hta6JmMarP2TN", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVG8Qdgn2yBsNRQDV7oW54r1whSEwvUk7M", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVsBXTUPszNJrdaT11rDSRewSdUjMQd5cs", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPmFVFFkB72o8Th9D2wJxgZaz6unt9HwW7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRA1jykbch853CAsXXt9sEGBjsp835v3P2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSXfJsEHYTcwmcsJ9yoekCD4HULQpxeCBd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZhb63HLT4RFyczAXhvviLHvkQUi9q6mTX", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUHuwaoxNusHj7ZUyTYjRFP9EETt2hixkV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRyvShLAW3tZEaydKZxLAA7R2GmErJFdn5", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf4FAqg7uo9sDZVwSyRctiGgHNfyr77HGD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcxYc7fFZjMCtE3GAdr6YduTcPzWXux7kV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRwYWNYRYpP42uucjSMiSmrpteCyJuaatT", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUw5tEoXvnGKK7bUKye9zGLyuumhJxE8ZY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYw5c1Ufeu3Xs6X4wDtEW3rY6mvJdyiV69", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZfcnG7M1KLuNVHFoAz75Q9axCPeZGvmnK", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSWBNaUoWQtkDioGsRQVMevrNjMBNhFA9m", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfmyojs61pnVshq3AMb3SueZQJmZWGS6P5", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbakZPnxpoFvUUA2ikFXEMfupnzL2g1Hpe", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgYwhNYcrYXEVL5vu6xgtB5egYpspHu8Qr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLtzQ3BiNMDJr6UtibNGKNY5q1Lek22mbw", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTxsroYE6NqWJEfCYRTN2kXFpvc1L6dywo", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QauYtokfSq8oZC7MQJkkRUCHwV9RCJXfop", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiLH8NVybYU3gfwXqmApeeLoebMmdxsvy2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QboQVDYaeYEWoxxuq638FGFwFZwVbv3wZd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTTMNosEkAGiFXLUVMczcmKA12Uj77Dm3G", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbo2JRDzV3DFruTjznyhzF2arhrKuNuz8C", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdbbfH115EuQzAcEPfV4adEVKEhq2rQE7P", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUQYqyWTkyXvnUxM3caZuEtDEDSbfJYpwd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdwmWF5FRsXNwn9aDh4SKWfhEuamnAXokH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNTqdLMtm1k6Q4iYpnvc5BcH9NBxanMkYC", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi7J6Wzb6pSgaeyGXYmbNo6a3J7csQncYW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMaJeJ5MmK4UG3Zto9JgLhJ26JaPKSp6ha", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QeCmhsUEgUe7ddLqCMHcX2be72BPFkQWd8", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWTdjKcTE8DpSopLJsy1H6CsqupZSU3ZGB", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QeDrfr1wmu7okNMRZmbJ2EwZMFesEv9zMV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QizC1Qtg7UUDu4bDKibiTKEMhmGqP7C1Qb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZPQfSTVKjRYJ3r5P6orJsYaz6ZcG87ktd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjt3WY2Je4xSBFA97ptGQBZfSpJb4jgxR4", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTPUBwrW6aRQLHQpPrw4e2bDXR3gRWq3kF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZLRzY7RZy3h6pE5CAk57RUvR48HH2joWi", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNAN8iRgrqKwPqrCojJQjBpEiEov5tirL2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiygUw8uXTLzDFJ2E7HBKHMHqiuFWCa6GB", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaZpSiB9Nj8WdbL3MHvUzZBXeFSqEMiD7T", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgaJRHFpD7WQEpPKVkUSNzxae4hSUyeJvh", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QeN8j74amkd8GFyoRcxBaVrkHns2WxKjmS", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLmQGf4imhLzZVAbX97MR9JFZik8JQ48B8", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QY81xXYuMaewGHHcrYNdxJzhi6dNqu2JRY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVm5E2rvGb5VVfWTAMA1VUAd34k7Gqr1q", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhGHn89LXfEj7y4CjSLtadvn8ezL6cAScQ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSCP8xGYg88n963nN9ejiDJeiNggwLRLpE", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdzyigNad6fXJxykm46yWRH5tN8uDq4beF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQJQfXRwypwuD6oRHjcdRCKeUhdrCsDs8k", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPCYAstwUstQDESpFbPBB1U28LEoRcPq1J", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QU4xvi1HzuxYEqQrxhKYnQ2D8hDmRimE14", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVgLeZ8poUf7CJJ2vUGUEwUiKJ1sgszTzE", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcU5kKi3mX1VJzne44LZLpr3htebQtn7xy", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdkxvWwbkLDnovvksuNDwyDHP3634CnSCU", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf9XqVqSSKDBG5F6AP7nUv1LpUMU6bmRnK", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNfqTKfSU3E8RWoJtJgnKxwJDkzfXb18cW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSpEUGYR2rveDE4ryRPVjizHtRKnMRh5fN", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd1GMBYjor3X8AL1WzHFi7egejb45XtLzY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbbzcfy5WjRejf5tHJLG14P3uvK23ywozr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWUSHC2Bpdr9PUNB8Hj9S7HF5iagWPWNEa", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcftPPiBPU8Y1XuCzdAH6vduXHR5V7YFhv", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QR6rz7SHGgDC7QnNzWBCo6idbcxcXiLfBz", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhgTuLEhWnxFCqCCADVP7Fh2oXXG9j3Dj9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTzaaunU9vbdibBHKqg5ZpVH1jcu4rCCm6", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNCas5mqJLgeJHB5jQKXuCVBhqPfRHdYMF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhqWZL5643Y5C3RwvBSJpv3W6FA8GbFWJC", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaVBebFHbsbhBcmrX9ocbnwEMJgzkHvMZQ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdbj1tS7ZGrNdKXqEKfnt9EnwbCLLzyoCY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjn22uhkomiP9H95G7XAbEVZFjKiGPfezV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3QnnhKZeUbjUtxGLJ8jQVb7XohfDy1Eq", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLqNGEMTT6GGi3dChtp56ocae6XxkakTkf", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZHLChVNfNNp5rsZQQKRRxkPGriUAyhK5s", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjrgs36ajjTFgrKUMvsDSW8xiNmDG1L2be", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbriz3o7KWJZCafQCt4ftJAAEh8Pvg8o8v", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgPQSW3FVHbEKf4UBZh1WwVhLo4eTSXa44", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSchsxiHAmhvA59HBy4M9y2JobH7nwi5Xf", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYEbJEiXbPjqKFpUWkbXBcFFUU1PbppZnw", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNG95Qwbb5P4DGedLHv2kmYvMjG3GxbCEP", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSFj8r42yFpaPQNwKWwSVQKVdozTniCk8G", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QM4JVyX65WbLUYTyptAMea2MHsefGvUcR5", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRey3hPPGc2ewP1Ztw4SFG1fyU1xiqLjCP", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ8cte5J5R21uypoaoCvAALzBkYSePZHDF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcCRjkP1XeD1dvwU4umQ9cFWv8d3hJqjK6", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUH61i6hsZehnXNJF5VefvLQcRCsNg3NPr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QagyUZdmnKJA9LyEqcxJFFf2ehmcqVZsKb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcCYuXos5xBXXHbRg1RTfSdxiZEkGa3N2P", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg3TKLhPvn7bKVrT9x37wJiJ7YZ4jBuqQW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiQUssukhoo1ft4G9Mxa8JpViqFW4PdBjJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTVLRwoopfg7qSG9CMfCPJz3UydnT3jDxD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QV2HChYd7opM1r6oYaX7KA5VUoKdiUuagg", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QV4f2XGsEFNgzabewhSPn1Gv3ZHNNps9fa", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVhboSLD1VmX2YvAnfAXkbzvsmXkDJZTNR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVxGkDgXt4nHj4MAd1afV9AxT1XCUVLGja", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXqkdj36XNHviKSyvmYgcKX1rb7HyXCxPZ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXZPc1JxDRWq2ruxuhVzirLqeb16rjpCc3", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QatMo1UEJwRoLMsV5PdYYXLV9RPGCiBMvU", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbYEcWjKDLTA9tRVkkNbvVT5924rBjwVSm", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMyn4hgtgCqpBTvXfhs4CWGmeKr8TzWkHA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QS4HpYpif8xWe2EVcK6rexii2bK8NqsiDs", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf5SRxnRThmY6eUoCVJhfK7vdsWP4fqEBR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaKT7xuHznaWqN1RdiU2WhNJCWtuDX7Brq", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd5NuuHK5tqTk8jNUF9mkR4zaLRbh2vUGj", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWW9iuy79tcFbHChCsZ28NDoxMAqMpPXhW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QP9y1CmZRyw3oKNSTTm7Q74FxWp2mEHc26", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe2ZksqXbXgJQ5kSErszCunbAEp7q5BBEB", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhQLJ1w8ChxuqpdCMqB5cB9YUfsnMGKQrC", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSUkFbuPdM11iN6LHTSyoc2XR3vir6CHSb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdWhwJckVbZCjCMDhLwsFuzAvwJv7y4r34", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMEL3mfTcS4NwMsR2hx9tx1HaqmjsFiji4", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXWCbFQ4fDdUVtcivCDySZmhbqSUw5CWKj", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYvML3UWBSwpx9SSQprJo12R2ZjLKXHY5e", "level": 5 }, + + { "type": "ACCOUNT_LEVEL", "target": "Qbzgu32EF5nPsanMMXXsMNz1rQ4hcmnGZA", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSCYGst9SJb4gz2H21Vq3DXquxzY73VWm2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh9Mx3kaTcWfoYJDgeQuDJ487K8EEwTtDo", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRQ7875Nwp9osH7GScPREnfLPX5RczZZ46", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjBn4bLZEeau7hx3Wae6ZWVtc7yCC79xEU", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMG32pXpVopiMA6HLoawMi9x4WmwZBpotK", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa8uuqfKV9yZekwTNU2JWnj8rnZc2RRmco", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhRiACrq2Xgw491jZovDL5UqvmVDGXQfdG", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZXGVbvKEp2G4c3dBHbTxtZKmu1x3k4Urx", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QX1oGefhKHCchNSUqycfzPjZp5NnwBoAGK", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSw5SHLYZLe5NKT2ebMLAr6BbYJNQ6rWrd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQsUPNpkB2iERBFqVHJomgPvBEookzGgFP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSqw9dxfGhQJTstNgkxJmig9YTxVFaTo3i", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjFpwBMrZKsGDmi8tGgXp1m9P7xxcr758T", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa64xQ1Qqmc13H3W8KB6Z5rRPsoRztKZuM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRpCadQWjcJHeieoUnXTiSpqydRbYcA6qh", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiG86w5cMT9iv7pekuRohJmFXUwkvjvMXm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgAUczBPFQz7UpVeukv8tEPGEtu4TkMAgv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRjAxvFYSsXSuwTgDQFAxFo1Vy8ntmij9w", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMc2ogsdyB9HUS6gka1XvJst6iWV6XDd1y", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjf73NRcLF18taDgZvrDXUNysViHiP81j8", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QLsnsdVELRJDkr355QCJXzzR29whaxbP3m", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgocHNWrSPTrUGp6oiSg9gwsvAHD8pYVHi", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbmbTWgUEH57JXHzdgUAy8H9HZD1Bzu2T5", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNXG4iaaPiqd2RLA28FJwCk6csUU7Mh6rZ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhsWKPi65qKKLVkn7DgopCn4h3f2W1FMvd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe2TzQdF5MGsfFbytqEosFkmWA24i4YaQM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QULHofsgHS3B3whoFNXPHrNMJZDv4YUc96", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTwx66zP3H7PxNJjtX41BZZB6nEEpXGTV8", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgjfghHZfQtNjjetUUNC5cHza4JebSAeyB", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QitLt9FeT84swMehxuWnLqKrtfhaaW8nzm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdLxR3bofPLP2ZkwHKuhW1KzetRNyFeW35", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSkzC9kNFxZnKFiMaiGsdVoAGKjfBBZwcz", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ4z5mEXUDQMqBXGQg5Cp2SvGMDTEZaXLD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJfNxUJvvnX9zRCxAFrMFz1YB1cYShNLv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQnyuF6J5AN7MvxZdxLL4r6qjYFmBpf6qd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJ5tQZCqh8KJmhbWKsx2uwUYXYbjyzUxz", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb6k1h4VvfoRsQeEnDSvsBe9PfJnsaRcax", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNjTDHtHXdVtfcRf8qTqSZgXLDiFBADBqX", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMnrK51UipWbtiA3ogm24mhe7WRAJ17BmM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNdQuxwAjHcojdxYfkxnnkMNMDZ2Ym6sH2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QN7HMxm2qCxHNTei5wmBfxFMr4cbb6xBAF", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfb3KNCYWsEzj7npPJxiNnQKw97Ly3BpEW", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg36i5Z5f12EYBR5PUZaf59Ub8KeEkYovh", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNy6RzB1xWykv3Yb6uUDQ9VgLTRGoFPLKT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf8cCCvv57r14pN4oJFVbLym4WWMERkA8H", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QV4VZbbdmuYtZKE1LXjQsojbb4nTJSw3wR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QReEeUBRRnsXUd4iMtgHAt6pfHZfVYD66H", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QThtnUYKiXtx9ga7LtT9qftafdiVDZs2tQ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbADUiFmkLpvyTZ6ug8kkE9j8aDcDm8W7o", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYYpvHzcUP4s9jSZzaNn1mqZDM4u26yAfT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQKCYjczFAKAgjYhRL1jjGcA6khh35Fu9F", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSa3xN2kdAwc6PBQw8UmFpK245cK34Aa8Q", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYuLzGnHLSvLtrDndk9GGPQnGU5MW2MtYE", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QN7zXUcHfBhn28qopFZ1R7pej4i8ndPibm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QedfstymDM3KpQPuNwARywnTniwFekBD56", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJMjw4LikMSf9LJ2Sfp6QrDZFVhtRauEj", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRE5pZcGwZ7bSEWh7oXAS9Pb8wxcBLwQdJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaeYRDhR9UagFPGQuhjmahtmmEqj8Fscf3", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUyubWVyz5PLPcvCTxe9YgVfRsPhU5PKwH", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdxGiYrxV4Hr4P3hNT988cCo8CqjyNNtN2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QY3jWQe5QbqQSMyLwf8JiMAbY2HRdAUQQ7", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbTS7CNqoqhQW79MwDMZRDKoA4U3XuQedT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QY8AQrKf1KE7MCKCWG1Lvh7q2mEWWqjnCh", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZtRisdwd1o2raPA7KhCnF88msVJoyc3uZ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdRio67LD8QCPmzXiwimvnNgSuXhdHy6hM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd6RrfCKZX3nx8wRCtJ6jJA9VJ4o7quuEe", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiUbpcT8Uibzua79RRzqbLqA3MUkWG1Q3W", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QXKBimE8Vbat755M9zmcKiiV4gkSLc6vfB", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUC5EMMVav2Qt4TDe9Af39reYoFnamxUkn", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjEaoBWyAP4Ff29dGUZtGsYdRvHKf8HKb2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QS11w9zba8LPhicybuvxkTZCmTLMCt3HZ1", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QW2LGY6cQwmGycv5ELE23z38WqXFzsuTFx", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QcL5p8mwKk9g6xpwCXTiHpJWwRUKqUkgKc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPHLQxDwymECG1deHhhxNkEM8jTH3rfmvA", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTR34yKDT59X1YJR4Y4HAnHJXXjwVHi1BM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QR9EUCjXzD7hQETjnZrKTsQ9XQAWjZtN3d", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQQUNMU47F6LbMjC7wVhaPgw34ytetgbLT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUuugD6cTY5p7RFGnMrV78dfmEBrAAYx1N", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQktyzttrEtkN1iHQNAR3TfS1T5Xse9REv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPaWULDvmSr1cwhNiYzU59fnZkQmLQafWe", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSvTyX62mGPTGLKA8TvmYcuct878LaLrsp", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMKunzBtoEQ2Ab8emix8KCXQR9dcfN7gca", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZezsFhUeN3ayGjJ2QnPJpG8tHqfutnKxq", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNBe19N5gNvgK1R4PvaYEinsAGTcbaQiNR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVkLgL3tx7aizRF64PAWnLn6VKTY4jGGXC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRsi8YiWAQKrBVNHyEAdcKy9P82NRK66pu", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiktanAj2ACcLhcCLWSAf3oboZdrkvWkcu", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QXKXL8hen7hM8W1fFHUAfKbPxZqqiimTnD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QP2NiMK9iATLo2bNRER3yuEk38VP4SC5jL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNkZHXusJXpreoxyo5ULyvPXZjkxA3UvEw", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qca4vfeoNVbtbHzJa5F3v8sWqZh51Fz5mR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiNYuBjPpwDo3b1iETYPZnZwtfQnpQGouR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QV3sZ8AtdRr8YTEPnmxE9tMt7wxX4ruG8U", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYkPYG6CEwpkxq5s3Sy6PHnn3SDXqvPSvb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhGkgG7EtqwuRYQ599DNn1jMfyzkNeZhKk", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdHk2qSAMeiPqYRACaNyy1jpAVYzTLrdyv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTrtqtdN1Kxiu8YDumczZd4QvyRwAzk7FS", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWxcJFLecFjrmejcCToGVRJpXueAZJgiEu", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgU2udWFXJpDatHhmXWqFLxYCkYGSDUGLD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhT1YEHJpbuFrTqkRCyeAn5QdeJsNzjX6p", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjuZxHoptE264839GsNhjcWgrCAzcbDQQL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QP9m28hRAd3Qz96CvBnwipR8J319b2sjzY", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QawVhDFeqEd6aGfgRxHsLN7SnrhGqweSHC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVwpXi2jMj5aMVjZmXbfDiQwhKY3FJyiPk", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZBRV9a8UBbdKaz63JqTnY3w2R62W1phiN", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhQDb2NkMXo5TALywQwJm4jp5CxydPsrqf", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVjGMUHTBkimNLGuDvctX8VPq1NkMAWJfc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYCd9YsdNpFeabaQVzoYUYAbUkXkERZNSZ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbcacPWfXdQe4HpDr5ddbFBuuURLaaS1Dr", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbSToBjt9g75sCM2SUEgJNb6uskeTyzPbb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeToG5yenFa8TfHJUE17898D2RVZ76tYiT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNBHmU7jgD3HyfD7qFzKAMcgdw7Pr3FTBm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeFMFEzN6nEtC42MdffLBB2RbyUKMq9BPf", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQmVTLRTBBQ8c2syo379Koydj1RNCAhUw5", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWWKVymgzeYECUwomWkaxioMAmpmotUh62", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QM8xDvrXzLRo41SjkNeMTYoP3tKsaLcQze", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRkhzEqETQczL8xHV8P98rwu84755SDbWP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QLiSBYE3QzNsWijsF8BTNzziLfyVB6nV4q", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QcS9jgiG6AptzioTUfUJr5oXJYQES275xU", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QfkPYKpTfotYzN5BhKXENDgt1f3vo8LTSp", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ9NUxhdgtvvxTSZqYY6k9qxHziqSQ5jcK", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ94CoMHUyNGxtef6QHMUNRd8D3NNUgM5V", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qce4PQd34icYvN463Smi9ahVGxznoax9Wi", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QR3MXpsu8ig4PJHJEfKsnDuxrSDLrDzLd8", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QXHQEBm9CtVTY9RNdCDxju7yr61DW3K8JL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhFG2as3oZVYSubieisTPHco58pgw2nr5E", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QY3dGvuVkQADmQYndkKv7sLBG6JeduhVbz", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMSx3vQagdw6QD4D9SiiDRMhDrNFGjhUpd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVBktFMtw31ye88qjR2LTkfFGgoRkXyf7w", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPr17q2iYVQ5kMEtmUmEBN3WpMF6DjzR2x", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZituXHq3AzDdi9PDhtA5jySAC4VBUk6UJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYaVmr36tGTH4g5iTCeB5tZu3u81yp6M1G", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa51wH3pbN1bDpDWwpDDRrccwVmZo4CdXc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbQB5hSA7P2ssdYXzxWcbDePL6SDDBUpgQ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeKHm4Rg7ANF6RBfphgS9gkYhLEboJUP8v", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSpjpN64bYczYNsKsgmNmNDAFiKUg9orJA", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdmwboMpKVpnvdYZgiaEXpuEnygDRxyywc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QLnRWFKRGRtQAmX2aGM1F5vXvEb7naUUBG", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qaqb6saKN4YuHVKJ2HEDgKWAzGhJQ43sic", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QadBYsejVVWyFneDMpCffjbBpxgF9AEatD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh6K3QdnKBkb4u5Z3wD73C4sTjvcBTgiFC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa7td7KrALcVXpMcv5GzvrtGMAPKHUUECb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaqNZLJBCJTas5Frp43jzxEYvEoWdgYeXJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUJoDzJ8WDG62MSpMfzxUDH1pwJ6aRWZL4", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeVt9GFpDSdg73XQbVdCU4LHgMp9eysYa1", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgzD9PSp1P5WVkyifGxCcoV7TXzLWL4GgN", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWDtjA76XhCfXw6gvYfo3MFcbKCX2ZEyLJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QM9wXFKoAYkmDwCkz1Vdsn9vyMeRRKRCzy", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWyp8eCTuCnT32vYQEj5rywCXWxYm36dov", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSGJrJSGub71GrjGSXJSZMFUtHEn7C5TUW", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNhPdmMHBUPJL6yvghnTFnRajMBmdqddZd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZaZctoQRrR2g1bhAfzb5Z5ZMANGVkBG5u", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUhct2oBCmaU6kYguDNcbU6HQss9QELpLJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSwQUg1aWMQ7kQcR6WMWa5SHxarGdG3DgW", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdQmZTpA8a2YnrZAykVhNpGhk4kVmjnwRL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVTD43EpJ4iFXKJwnofocwcopw1iYo1TP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeXsnu3X1FsmLMRPYPJcsfJBVrTtwW4qrR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVTgyvvRGrd56BrFLvQoaF3DAYBXaobwef", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QczCL1E9G6fpifK2pFgDQiV2N5M7X54vAV", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYQs34RxFv7rtYAx9mErUabnJDvCfBe8gY", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QanakqWSmEB6oQkrWVDRArG4wTHPs3zw4T", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNiGHSk13xXy54KuCqQ5PQZBQa13DhPb84", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QemRZy1gnzY1j5czckXAoBqW2Ae32onBPn", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNzqkJgXKy4Gi22hGgyMMThFeG6KSYUwEb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ3cnhqAJVyCwYBZmgjnvDz76bKyJCXa1d", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa4ZKZEgKNRDNADY97aB95VYQMa2CUYV7y", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWceBxyxTA9AUocwwennBg3eLb97W5K7E4", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaX4UkVvH27H3RkMtKebMoBvJCcEbDiUjq", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjJnJQfaPYJdcRsKHABNKL9VYKbQBJ4Jkk", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhqaWQkLXTzotnRoUnT8T6sQneiwnR4nkM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQwemW9rxyyZRv428hr374p92KLhk3qjKP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaeT4E1ihqYKa5jTxByN9n33v5aP6f8s9C", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUaiuJWKnNr9ZZBzGWd2jSKoS2W6nTFGuM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVQroJR4kUYmvexsCZGxUD3noQ3JSStS4", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPcDkEHxDKmJBnXoVE5rPmkgm5jX2wBX3Z", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QfSgCJLRfEWixHQ2nF5Nqz2T7rnNsy7uWS", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QU7EUWDZz7qJVPih3wL9RKTHRfPFy4ASHC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbpZL12Lh7K2y6xPZure4pix5jH6ViVrF2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUZbH7hV8sdLSum74bdqyrLgvN5YTPHUW5", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiNKXRfnX9mTodSed1yRQexhL1HA42RHHo", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QT4zHex8JEULmBhYmKd5UhpiNA46T5wUko", "level": 6 }, + + { "type": "CREATE_GROUP", "creatorPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "owner": "QbmBdAbvmXqCya8bia8WaD5izumKkC9BrY", "groupName": "dev-group", "description": "developer group", "isOpen": true, "approvalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "2qRDBEKrarZGvePqWM8djfAsa8LMw3WCcG7UmGni42Rk", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QhQLJ1w8ChxuqpdCMqB5cB9YUfsnMGKQrC" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "A5RNKWchwQisV89MXBsD36mXEYJYUoCqtMenhHRaWNt7", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QSUkFbuPdM11iN6LHTSyoc2XR3vir6CHSb" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "B4Yvir9qMK1SHoqffiyTj96ke9ZAKzvpybwURjy4LxsR", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QdWhwJckVbZCjCMDhLwsFuzAvwJv7y4r34" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "4MqhFijJJPjrLQVaUaAMPBpRhQH7uPKNDkgVMXdZSbVh", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QMEL3mfTcS4NwMsR2hx9tx1HaqmjsFiji4" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "FmSzBdj3kj8Uyin3pUzBNDHTfZ3dMKYFEJJkjeP2sDxq", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QXWCbFQ4fDdUVtcivCDySZmhbqSUw5CWKj" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "2qRDBEKrarZGvePqWM8djfAsa8LMw3WCcG7UmGni42Rk", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QYvML3UWBSwpx9SSQprJo12R2ZjLKXHY5e" }, + + { "type": "UPDATE_GROUP", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "ONE", "newMinimumBlockDelay": 10, "newMaximumBlockDelay": 1440 }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "8q7oSa8YQqTSvPP7aC3P9TrSpXbqp7zdYxbiGCHzv5Wb", "groupId": 1 }, + { "type": "GROUP_INVITE", "adminPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "invitee": "Qe2ZksqXbXgJQ5kSErszCunbAEp7q5BBEB", "groupId": 1 }, + + { "type": "GENESIS", "recipient": "Qe2ZksqXbXgJQ5kSErszCunbAEp7q5BBEB", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QhQLJ1w8ChxuqpdCMqB5cB9YUfsnMGKQrC", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QSUkFbuPdM11iN6LHTSyoc2XR3vir6CHSb", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QdWhwJckVbZCjCMDhLwsFuzAvwJv7y4r34", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QMEL3mfTcS4NwMsR2hx9tx1HaqmjsFiji4", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QXWCbFQ4fDdUVtcivCDySZmhbqSUw5CWKj", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QYvML3UWBSwpx9SSQprJo12R2ZjLKXHY5e", "amount": 10000000000 }, + + { "type": "GENESIS", "recipient": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "amount": 1000 }, + { "type": "GENESIS", "recipient": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QPFM4xX2826MuBEhMtdReW1QR3vRYrQff3", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QbmBdAbvmXqCya8bia8WaD5izumKkC9BrY", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QPsjHoKhugEADrtSQP5xjFgsaQPn9WmE3Y", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QdV7La52WsJz1Fr7N8wuRyKz6NbZGEQvhX", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QSJ5cDLWivGcn9ym21azufBfiqeuGH1maq", "amount": 1000 }, + { "type": "GENESIS", "recipient": "Qfbyw8g4uMnwqinozQsbrXF1WisFt1NmbZ", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QbbbBLembrrYy8kA1GEnSUTRRX74nKFVVv", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QdddDvehhYdd67vRyTznA8McMYriNVJV9J", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QS7K9EsSDeCdb7T8kzZaFNGLomNmmET2Dr", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QWW9iuy79tcFbHChCsZ28NDoxMAqMpPXhW", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "amount": 1000 }, + { "type": "GENESIS", "recipient": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QbpZL12Lh7K2y6xPZure4pix5jH6ViVrF2", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QUZbH7hV8sdLSum74bdqyrLgvN5YTPHUW5", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QiNKXRfnX9mTodSed1yRQexhL1HA42RHHo", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QT4zHex8JEULmBhYmKd5UhpiNA46T5wUko", "amount": 1000 }, + + { "type": "GENESIS", "recipient": "QLxHu4ZFEQek3eZ3ucWRwT6MHQnr1RTqV3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3DW43uTQfeTbo4knfW5aUCwvFnyGzdVe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNxfUZ9xgMYs3Gw6jG9Hqsg957yBJz2YEt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQXSKG4qSYSdPqP4rFV7V3oA9ihzEgj4Wt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMH5Sm2yr3y81VKZuLDtP5UbmoxUtNW5p1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRKAjXDQDv3dVFihag8DZhqffh3W3VPQvo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXQYR1oJVR7oK5wzbXFHWgMjY6pDy2wAhB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNyhH8dutdNhUaZqnkRu5mmR7ivmjhX118", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj1bLXBtZP3NVcVcD1dpwvgbVD3i1x2TkU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjNN6JLqzPGUuhw6GVpivLXaeGJEWB1VZV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbgesZq44ZgkEfVWbCo3jiMfdy4qytdKwU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgyvE9afaS3P8ssqFhqJwuR1sjsxvazdw5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRt2PKGpBDF8ZiUgELhBphn5YhwEwpqWME", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRZYD67yxnaTuFMdREjiSh3SkQPrFFdodS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QieDZVeiPAyoUYyhGZUS8VPBF3cFiFDEPw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV3cEwL4NQ3ioc2Jzduu9B8tzJjCwPkzaj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNfkC17dPezMhDch7dEMhTgeBJQ1ckgXk8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcdpBcZisrDzXK7FekRwphpjAvZaXzcAZr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qaj7VFnofTx7mFWo4Yfo1nzRtX2k32USJq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRchdiiPr3eyhurpwmVWnZecBBRp79pGJU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QemRYQ3NzNNVJddKQGn3frfab79ZBw15rS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW7qQMDQwpT498YZVJE8o4QxHCsLzxrA5S", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM2cyKX6gZqWhtVaVy4MKMD9SyjzzZ4h5w", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfa8ioviZnN5K8dosMGuxp3SuV7QJyH23t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS9wFXVtBC4ad9cnenjMaXom6HAZRdb5bJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSRpUMfK1tcF6ySGCsjeTtYk16B9PrqpuH", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qez3PAwBEjLDoer8V7b6JFd1CQZiVgqaBu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP5bhm92HCEeLwEV3T3ySSdkpTz1ERkSUL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZDQGCCHgcSkRfgUqfG2LsPSLDLZ888THh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN3gqz7wfqaEsqz5bv4eVgw9vKGth1EjG3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeskJAik9pSeV3Ka4L58V7YWHJd1dBe455", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXm93Bs7hyciXxZMuCU9maMiY6371MCu1x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWTZiST8EuP2ix9MgX19ZziKAhRK8C96pd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcNpKq2SY7BqDXthSeRV7vikEEedpbPkgg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhX25kdPgTg5c2UrPNsbPryuj7bL8YF3hC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcx8Za7HK42vRP9b8woAo9escmcxZsqgfe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjgsYfuqRzWjXFEagqAmaPSVxcXr5A4DmQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXca8P4Z6cHF1YwNcmPToWWx363Dv9okqj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjQcgaPLxU7qBW6DP7UyhJhJbLoSFvGM2H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjaJVb8V8Surt8G2Wu4yrKfjvoBXQGyDHX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgioyTpZKGADu6TBUYxsPVepxTG7VThXEK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcmyM7fzGjM3X7VpHybbp4UzVVEcMVdLkR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiqfL6z7yeFEJuDgbX4EbkLbCv7aZXafsp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM3amnq8GaXUXfDJWrzsHhAzSmioTP5HX4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWu1vLngtTUMcPoRx5u16QXCSdsRqwRfuH", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi2taKC6qdm9NBSAaBAshiia8TXRWhxWyR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZko7f8rnuUEp8zv7nrJyQfkeYaWfYMffH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcJfVM3dmpBMvDbsKVFsx32ahZ6MFH58Mq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVfdY59hk6gKUtYoqjCdG7MfnQFSw2WvnE", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhkp6r56t9GL3bNgxvyKfMnfZo6eQqERBQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjZ9v7AcchaJpNqJv5b7dC5Wjsi2JLSJeV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWnd9iPWkCTh7UnWPDYhD9h8PXThW5RZgJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdKJo8SPLqtrvc1UgRok4fV9b1CrSgJiY7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcHHkSKpnCmZydkDNxcFJL1aDQXPkniGNb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjaDRfCXWByCrxS9QkynuxDL2tvDiC6x74", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS4tnqqR9aU7iCNmc2wYa5YMNbHvh8wmZR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiwE9h1CCighEpR8Epzv6fxpjXtahTN6sn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRub4MuhmYAmU8bSkSWSRVcYwwmcNwRLsy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLitmzEnWVexkwcXbUTaovJrRoDvRMzW32", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUnKiReHwhg1CeQd2PdpXvU2FdtR9XDkZ4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcSJuQNcGMrDhS6Jb2tRQEWLmUbvt5d7Gc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQQFM1XuM8nSQSJKAq5t6KWdDPb6uPgiki", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWnoDUJwt6DRWygNQQSNciHFbN6uehuZhB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZppLAZ4JJ3FgU1GXPdrbGDgXEajSk86bh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNHocuE5hr64z1RHbfXUQKpHwUv3DG4on4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5SMHzAyjicAkMdK7hnBkiGVmwwBey1kQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhauobwGUVNT8UkK41k2aJVcfMdkpDBwVb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh31pAfL5dk7jDcUKCpAurkZTTu27D9dGp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM1CCBbcTG2S6H1dBVJXTUHxhfasfTR6XF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ5zUwBwfGBru68FsaiawC5vjzigKYzwDs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmFjyqsHkXfXwUvixzXfFh8AX5mwhvD7b", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJ8pBwaXUZ1C7rX4Mb9NWbprh88LeUsju", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMLDPdpscAoTevAHpe3BQLuJdBggsawGLC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaboRcMGnxJgfZDkEpqUe8HXsxFY6JdnFw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVUTAqofenqSuGC9Mjw9tnEVzxVLfaF6PH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVCDS2qjjKSytiSS2S6ZxLcNTnpBB9qEvS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfEtw43SfViaC2BEU7xRyR4cJqPdFuc547", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf9EA2o8gMxbMH59JmYPm8buVasBCTrEco", "amount": 10 }, + { "type": "GENESIS", "recipient": "QddoeVG1N97ui2s9LhMpMCvScvPjf2DmhR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QajjSZXwp33Zybm9zQ62DdMiYLCic4FHWH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZVs7y4Ysb62NHetDEwH7nVvhSqbzF3TsF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP6eci8SRs7C6i1CTEBsc7BkLiMdJ7jrvL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgUkTPpwsdyes7KxgYzXXWJ1TnjUFViy9R", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVVUs58P3UimAjoLG3pga2UtbnVhPHqzop", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYVhnvxEQM3sNbkN5VDkRBuTY3ZEjGP2Y6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgfcck7VX4ki9m7Haer3WSt9a6sEW7DwKm", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdwd54nUp5moiKVTQ7ESuzdLnwQ9L7oT37", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiPTyt2VgN7sJyK2rCfy24PQhoL1VwvAUs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXNABfSfAFRDF2ZCca4tf1PyA3ARyLUEUK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZJjUVgjoacvHmdjfqUDq3Dh6q3eTyNh2y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWHzcbXSrEg7AiVDLBhsR1zUBnWUneSkUp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLgjnrRRCkQt7g7pWQGAXg99ZxAC8abLGk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPmFGR56aQ586ot61Yt1LX79gdgBYGNeUN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQb493uqAUrWe2YoNR8MmhhxjNYgcf3XS6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV3UDtxFyXCsKdmnVWstWQc1ZMSAPp1WNE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV527xbvZNT1529LsDBKn22cNP9YJ6i3HF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQAbKyRGv8RUytDyr1D6QzELzMvNmGnuhZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP2xZTDDu6oVvAaRjTNW7fBEm9fcjmyjAF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRH9E99H893PS8hFmzPGinAQgbMmoYxRKj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRtqR9AqsaE4TKdH4tJPCwUgJtKXkrzumk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaEyGRLnR7o85PCRoCq2x4kmsj1ZuVM3eo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUZSHjxYNfa6nF8MSyiCm5JKbiRnBy6LZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXUozAco8vrZgc3LZDok4ziQdUb1F2WNiv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZF252FDKhrjdXUiXf16Kjju3q23aNfXWk", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj1odhqTstQweB9NosXVzY6Lvzis24AQXP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTaiJKCnV9bfbEbfbuKnxzNU8QEnYgv4Xu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYLdKUKoKvBAFigiX2H7j1VcL8QaPny1XX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaEfP6nFkNrDuzUbcHWj9casn9ekRJCtrg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcbQcC2BZP9AipqSDFThm3KWfycn9jweVj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfGLmDwWUHhpHFebwCfFibdXFcMZhZWepX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkUwfBU1HKUius1HrEiphapMjDBsFrJEd", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qab7N4CYsATCmy8T3VTSnG8oK3Uw3GSe6x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdJirbcRUTZ4M6fBAmKGgsvC7DVpEqQLrt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSPVSpKZueM1V9xc8HD9Qfte5gFrFJ61Xv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcuAciBq8QjDS2EMDAMGi9asP8oaob7UFs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNwDgR34mYsw1t9hzyumm5j7siy8AMDjST", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf5RGjWtSn8NSpYeLxKbamogxGST3iX3QY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYrytjgXZmWsGarsC3qAAVYdth8qpEjjni", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbHqojw2kSmcsdcVaRUAcWF2svr9VPh1Lf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXsrAcNz93naQsBcyGTECMiB3heKmbZZNT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN4NnUvf4UwCKz9U66NUEs6cQJtZiHzpsB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRXzd5xi7nPdqZg5ugkoNnttAMEMAS7Zgp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZmFAL7D719HQkV72MnvP2CEsnBUyktYEX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT7uWcs2dacGGfLzVDRXAWAY5nbgGjczSq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYhu1Yvx4wEcMZPF7UhRNNfcHFqWKU9y8U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeEY7UgPBDeyQnnir53weJYtTvDZvfEPM4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQszFsHkwEf1cxmZkq2Sjd7MmkpKvud9Rc", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi8AKfUEZb6tFiua3D7NMPLGEd8ouyAp99", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYMortQDHVwAa44bfZhtoz8NALW3iE9bqm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMptfhifsYG7LzV9woEmPKvaALLkFQdND4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR48czk5GXWj8nUkhzHr1MmV9Xvn7xsyMJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRmrBWDmcRz1c5q63oYKPsJvW5uVvXUrkt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR24APnqsTaPCS5WFVEEZevk7oE1TZdTXy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPUgbXEj1TfgLQng6yHDMnV4RE4fkzxneP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhZH9dcBwJXRHTMUeMnnaFBtzyNEmeEu95", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeALW9oLFARexJSA5VEPAZR1hkUGRoYCpJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgxx7Xr4Ta9RBkkc5BHqr6Yqvb38dsfUrT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcqiXKsCnUst4qZdpooe4AuFZp6qLJbH1E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQLd58skeFGRzW9JBYfeRNXBEF6BbxuRcL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfBvQKMgWjix4oXPZrmU9zJDv8iCT4bAuv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QamJduVxVwqkUugkeyVwcEqHSSmPNiNt4G", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeYPPuzXey13V2nRZAS1zhBvsxD9Jww8br", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiKu8wuB5rZ4ZvUGkdP4jTQWBdMZWQb4Ev", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhhhQhVeJ1GL3oMyG2ssTx7XLNhPSDhSTs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPfi9t9CAPVHu3FGxRGvUb723vYFUYQEv6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWH9V5WBEvVkJnJPMXkULX9UaDwHGVoMi6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYWoBSTXCRmYQq1yJ3HHjYrxC4KUdVLpmw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QftjmqLYfjS4jwBukVGbiDLxNE5Hv5SFkA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMAJ2jt377iFtALB3UvuXgg21vx9i3ASe9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaP9FzoAQAXrvSYpiR9nQU6NewagTBZDuB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZpWpi8Lp7zPm63GxU9z2Xiwh6QmD4qfy2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPNtFMjoMWwDngH94PAsizhhn3sPFhzDm6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTkdeWxc34v5w47SDJYC9QFz9t4DRZwBEy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSSpbcy65aoSpC3q5XwEjSKg15LG868eUe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhcfCJ6nW4A6PztJ5NXQW2cUo67k2t4HHB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYqv8RVp57C9gaH8o1Fez3ofSW24RAfuju", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVgLvwFNNjHAUwE8h2PcfKRns1EebHDX4B", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSv5ZY5mW7aGbYA7gqkj4xyPq4AECd7EL8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgyQ9HX5JRbdKxFTXgsoq2cnZD89NwxinT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTEpaAMni8SpKY8fd8AF7qXEtTode1LoaW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeKBjbwctfydGS6mLvDSm8dULcvLUaorwX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhfG4EVSd8iZ8H1piRvdRC8MDJ3Jz1WcN9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjXYs5HWfda3mgTBqveKatTWHnahv2oX22", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh1iJg1BEdoK4q4hjXcSkNE4qv9oYsHoF4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPBcSVqzpB3QhiwMkiq9rMHe7Mx5NynXnD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUgVsyMPFxjiS2o5y81FoXoiWHiAwfbq94", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNH3ebZTv6GeWwjwhjhGg7doia6ZJjqQXG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXXeoduLPuhfURibgkfEfSSQ2Rom9SELtL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcKnXTjEaTBr91PQY7AkCxvChNpkqU6r1t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhWNbSmPAoAg8bXirPeNyGVuoSk84rfnHu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdivX7dtJKosr83EmLTViz7PkFC4FQqeH4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUB6fPHDTrpYyU6wJmAqV6TUBZiWLrTPuz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiG69VVGp13oCiryF4vpDu3a2kEEHi7HDm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS4dJJhwCheoMB3Z8Mk8wNZFfSu4FkW9Vv", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb6peGujesgEH9aHd19NfKvR5vTmsb2oHM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh5tovSQykjFNJGV1P7tGtfmfnJXQQNLr7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS1vPBzGLu8ZskZtapcYzUCr8pEjVxtFgu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUJmfReuva7PmyzFBr7M35QuYZcAoeWPyT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLfYVnUtR4RVcthhzYc7U76vmK6LkyUky4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfeNQecGDhdHSdoTDAKAaAdpmgGBfJjQw6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiPHkz2YVDhsJPdkD7qxizFFEu7m3g3zA7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSmmCGNkGbqwGGvdeBtkHBPa4pXXEG2vkf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNjN51iZaZb3ZnfNiLdm1xtUZ4DKLj9X7e", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjFocrHNieQ8rDYifrZTWtYgejjih6mmS1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSNDFgL3bfX7Pe9FaD7p1G1rtJe5v9aYsV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWf8uFUXCahEXLV2cjJjunimCJdnvsN3JM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSzVqpvkjfFAC6sJcyefyouP1zYZycvwpm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX3zQTmhnm89PrW1nfs6YJDfiAkegzpD1S", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg6kovZCzF2GKNyMoeJSaUArvzKJJH56L1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhP2ND6q5Sptsy5pQUo18AuTgKMBfF4aPr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT3Cu76gET1ezemDVCojoP3SLMY4xNDH7k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdsqubwFQ1hChYwzpHvKAiLF9JMWWEwXhp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXE9M12CjPHBSFTS8DFUWjab4Z7F1JeRw1", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa5e8Pz4sM7RSAbwvM2N9m5NyYAgm2Fo3J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjoNujTmVCDVoR5M99NMBrGwuJCVZUSWJ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMmVSM2dmfhRjGMCZaLeBGU7kXGGPeiRZn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYh1Ht5c278CPs56khy4iH2YxXZrtdMGXo", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb3m52qr4jcsidw6DTPJUC62b51rM61VFj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTC76DrGsCJuT4ybDiDTFaTxjXTPUJcpUi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRqBqahzem4MpJarmGYh1jyaFHYxufssY3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZpoY1W7MJvu5uJwdJRbKwWBhVhYPRAgag", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPfvtXRAWazxK8CrSRvDoCtRG6Hy3ujCx4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgJ8Ud1qJHfdC6wyaUNcigUHJ65Udd2jYh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRQtHawUKGY7g68yabnneKo88BFv35ddMD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYFKaYFjRe8iYDbwUBTWjmPGosjcgBtC3Y", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc3HUdiKbHaaFK83p44WVicewmZip1TnAj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYwkYWDsoJAWHPN1dHttMZ8QPABbriRMov", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVNwVTRnJNL7HYpHZ7wppApTv8H3FxvPXU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaxHEi7urRTZbGmcpyCcJr6zQZbDAnbfJt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPqG2UHH3ueqsjm2HMUuQj6GQW99VVXJry", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc5LxN2SQCQfJLVatuSMtmJtAihjapL3Qg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgMgsYiwyRiUYMHKCdB5tLJxuCroEbJnq8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5NyYUUPuPvkkvazYyYjTT9ef7eZU8of8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfNd6YADJq1M4SbwBxLKQ3AD7GEpTpAJi7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZfGRwx8K1AyYwPUXHa9Tn16KP2h54iwfr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNB1kaRHYBrmDRHepqxad5DYxQPbjVG4As", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTpjvRCrvWjXoBzSG379ZsEwW2F5xoLSiP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZhnNK5FfX3FjTwwYwbewUQGE64Vts7qXP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNFmGsWLr7Y4qngz1maq4ptzhcUAJdjDU1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR3hH2cxYz9MgDBq3vthEbdnFVMJvprzyV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjjDaHSiAaPP8p3CRM3STeBc4VD9SCY4TP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRppWy5shqf6TPZfh6CAfjPB25aLWPiNub", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgaszp8eniCvsFiVHaBNNDToaVVYjLdLeB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbdHAJur3Vg9MYCPcgsz4dNW9gDGp1f727", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTxt6nMZmyZCJVLcsxZmwt4sUv1bFkLLRi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVbpnTE83PfopgvXY9TD92aYWQrTgvGN3Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPdfyB2zwWt77X5iHeAKr8MTEHFMHE3Ww3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRUgU6YptQd85VWiSvLUDRoyxnTBPGRHdx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRLDb39eQWwiqttkoYxDB5f5Bu8Bt6tu8P", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUB67e2qPecWexgCB98gr3oHqMN2ZVay9j", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhK3xN3Ut6W1B5pg9MJdTLyHLAGLjcP7ma", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qai9X8cd9FdZufFH5rcKYodp6s4AQqH2XF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX2YaXwfrEDNzUAFWRc3D17hDaLAXw42NQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMUMUzgWeXhUJsWxa7DWVaXDzJFrtpuPCn", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg8hVCdNiRy7Tqs2EHqLWtydqp1wzYc7Ny", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXG8YWRehGa3aLTnnMupmBrXeXS93YuwmE", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qadt3251BYugMm2MjkmzCjrzGp2MfkJicH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaiosSXjrXXca8vLpNwKh8qijdh1rd23L3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP5tVLY8CQqQgzMuTPrxz2XpP2KDL9neNV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjeebj5TZqG3y8yGwWT7oamPxEncaf5fC4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMM6TbkySGcRkxdpjnmeRcYgL1oC5JKR7X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQAFHDRg2PyR6UMR87T2DkQfizMR5VhStM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQcXsQRpHtPjECVp55Weu4ohoJK6pK81vu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSrT7WTzjs6jnwZpDmcD6NvD2V3i4H5tq5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWAagG61SiQvfSbWS4vQnvmJbyCJ7GSXiy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQP64bevncP8kZ9bxVP5Brp8moK1rsPsBk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUuGEuWwQyjMgtxzAhcvmsQhE8VzsA3vjt", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa7iysVRdxo3KzYSi6JAqAYf4NFfFDjWLj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcQdJHRGgvL3AoR9LSRSjVNdczukw7PKQe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QedHYrn1QkrRZBkRu5kkajgqh5bcD8xZkt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUChBcWdxZX1VFHGwrUjRJbqbXjRdPNyki", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNW6zHWRyzaMPbb6JbKciobqbxtuQSZgw4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPFA5p13WYzzhpvHCGDoHtiA2oKAxPeKhU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS2ekPtGMR2obKdFKqFAcJQ3rbZmrzBSRz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSpVwNfiKEh3NiBXduS8TnJXwgyHYmfFqH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYm6g3WqAKnhotVwSLjqzorpVhzn2LgctL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSfifb4e9W8C1K2uaAcwvjzqN33fmMcVwR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYrLivTTHat8xFeJKkzrJXSyHeWkuBhWVA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTxcZ3kMi7msQCkViFwWLdhkShhNNVa5Wv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNS4HmJen6qDVqSAYszeHKfaf1j1662tj6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPBri2D8WYjxVZYd2oKgwvXg94FKweytBQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbeujwVbYFLx5uQBmkYs1a6cZRAopeB4cD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWQUSSqBRQBiNnDu3ZGNGTXJyAfbLf5MxK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPt5bnE51SzA6VES5kpdvpNHiFeHHMKWc8", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdu3N57EXxaZ8TXRfHbEa8QuqbYW2sot1t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU29ppZiJ9Vzw4tQBrXdPJZToWhpu9Dp9Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbrkBLFcmRUA21u5QsrPBpzrDH2wXpK7V", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP544WzvAVh72cCVGr2WKFMzpicaH1wqAY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgKUwvnhj8tHWbNb59s9nkHQdapgWNcgAy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSCminTT9z7qmx3zEvGZ221B5rVNvjBsK4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaSrZL9TyKNUMfge6YiDatURrT2QHxNX1R", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYg2fLR5jXjStMhzUSq7QJ5uEbTrvRXRYt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUWZa7s85qeLC6uWKTsMXnJ4BQbMiBddZB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbUSdFauEMKMHq2kAfX7BaLknVME6FpJhj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRXMXw7CT1NahXwj19t8wHHAuUFAMYm6NK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWr5TR1trHvVh1JzQbRARKqjJaMiywYzgr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSjVVpSLeaaFcV1XacFJUXpBoBB3paFVPY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQaDSZPWWFcFPGj38g63aP2gngvcgJnmsa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcKk4AGz4FwYA56C7wAZW9Ep5Fimf4c1Mo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQJnvY3h86m56EGfWKzaVZnFthNDAUdYFo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSY6Ps3vxs1XEyFugvAWnv8a7sd1WuZkA8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjGbYagnZyc38Sm2M7gbg7wNX4Tfp6kTSs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QikfKyFmSWN12cMHVzEurCrfS4KEywessZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZRnbiNgLsGjd4pCrWntwSaGU3Ex4sZfLE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTyUpPTd4n3Qk9k6k6ifKnB79XHueE4M4X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QftHwRjwREQ3goEzehhF59rZUtrqrBGH7P", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhy15ZCfvjcDiQt97YcipgwK3paNQWfSAT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfhGGYFr8ANfCg32VcvULCqcofUybRbHYJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMZqtWiJjH1JUqy7roNi95ByGvzFThxDXy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR17PHMYpHsfhQ8NXPVSVzXG3puMn99YfU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVJKdAoPLfnShJFk1cxcu8h7z1SvPTaVyg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPByWaTGBToyDNhGMMBgGGRtLPD9V4h5Vv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVNWqbd7ERjn9dcqBGwmUcseoiwQCehey7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUQiDpv4PzjHLz8bYk8FJBnzrmjKYc6bsr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXtNoe9v7bsfW6w8uJweXpo4JESHoxWium", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfPfxADaYrQUrKySf6tJBtMHA8cNG7VtNe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQjtX9bro4bRkS1B3FyfAihyk3vZkQm8hZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qidvt5WQVMqgcchxwGdCd2jp4cCdGioA4H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhfLqEaDKmbynhKYK95BQJtseH3cqEEURD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYyyAFBUXB9F91KwHCQNuFDGuw7L38fi4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbMdmYjG4d71FUjk6L7pEoszoC9EQH1zUN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWxFeuRWE5GZXNfZ2tYqW3GmAC3FAz5Qrc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbG62vQnBrtYJ2VwuJSzfA8NXMj36FYbb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLoV9cxAUkPn2DaQKnqDVJq6jMN3k21JAM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcsBjck2WTR7J3PmQ9RXHxsPewPkbxzCtp", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj29EdPyW7MhZ15XDgvGZwXrmsP84KM5ff", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUTg3JNn6JGtHy25XTgdNu5APzp5cAg79v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU1C597JwXXBbR2ysX4fKGr9DTqbn1bPxE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgboEdXscGVZ3pFyUq7x9ufaRmDseeb4dC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNWtJX7SDYBQxsEqmjsLbhVQoAYV3QkynD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY1KfMNNtBe1q6JxGzGimxM3vpCoqzQCNX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYp7DknXc9PbdF52vTozrh1ZEfM7wZBhFG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfshJREL1rFXcBDYTZQcj8mGLpQh3ZWC6t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNLBihtJXLo3HVjzLGgdbgbHacTgMt3USC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZkKsgF78HsiDef87g7dGLGKsoTSH2ekWT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWv4gyJ4N1WxCLvAmWKLtx5mmBYAqXHTXD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSp5oQ65SWNbfampnxzgBuEymJLVkarPBV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLddFbuRfbkrMQnpHA3gvBtYERfqwRdJsC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMHWrDejEvBVuzQyUhnVqnSaKKMHyCosyF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVwFkDM51dcvCfmvYUBjjQg87JteNis7f6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTvu4zok2UGnB45s1Luj6v3AzMRUEP1zmd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYh6BPhuScCt9ENbnAcp16mCZLsYnukMWY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYH3WNEknRKSFViWuZzmN43q8wkAGpzKXu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZwweWZAURCtoLM8K1ouA7McNyHNjyDcBi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ5RBZkiqGhvCnQvCPPZar8RqhtwDonDBi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWpt9ZPYks3PE8nHLyKkoLogD3doMumrK6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdds3wCmA2P4kkMXHJCi1JuQVMLJayskQu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYkkDoVQCHZQM3KJQC1J8qFVZmXi3T7JZe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM44Ks8EALor7MNhQGHpUpqu484VeUYRAL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeBeZzP6xxSk1hem3tRzchzAAMgRKb3fkg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QThNX1VbEGbAE31sjZKZYBBg4CNX5JkbRr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQiz1NcVPECxicoDXQ1p6h5yU6KozLYFhj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP5h7AugR5sY2U9YLHjmTTkuoZFWoomar1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY69G6HF2SCnqEPJwwHrnBrXn6UwccfSGa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhPFgRDmwGdjexK1nEA2r4caPXG4SRVXCD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQrjQEssGwc6ixp9N76b42By1sFbEKDTDZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgnzw5Drcj1LvipRbcCPS9rG1PSyXF91nn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeLgbgD74BgbBpoPE2jJuNQN5GqyBYNRev", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVPE6V1xpZpVz2Zhu3SNkKf7TgWPAqRo4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY7NTMeAq2Wt9BZYf4BCwj3eJG5aMYADRu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVj9GHBnU1T9yseTTR3j4PST8aaLGNPpm9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVVvANL8ML3RaMbF34aoxL3z1bSoznTSC5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfxrJXCBbnvGqCSztwDzrNzDaBYQK8Lejr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZKQViyTqY2D9zQN1k6pwmKaKE1ooaf4UZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSv8WNg5HwfU68NcGyMEJ3G9pQLGVpHwFk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR6QPuyzBtPFB66SheLhiUp8sqgyvrXoVs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNPSKjSd8BdKV9y8w3CuU8str4t6AtS2aA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT2GRgCRBJTBCWVsoxax2kNFi4eGq8DZ7Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfNphDAZBZPDmtakni5PThJxdbi3xufDr2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZoLPzXLePhs7VcLMGRZ9qJxCb9rzqCJmK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiDq33pUvSHi2pEZ3cGEPVtiw1i6FzV9ai", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUNGLbMWTQELBUQN4XUkNtZQehvyZaDmAC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZtiZnjjATzg8dEoAikrbQfdjhgGcCTxsT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgAt6xyNojsoDpJvcsUPkdmpz5TDp7gZh3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYwUGeqXJZDrtMh6QyUT4SubuPqm3nXkYe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYdqgA8uYhcec88NxVr7wg3WReUQqGVHzn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhpbiBSUcTUu2Ex5pTyTS3SodSyfKmtzyx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiTpmEJEstonzSsvuCvkmBQpf7jaNuAuq8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSTUkD8xB9rkYNzAhZFdSAxan5Y5KqirtW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5q7B665QkuJtJzNnSnPuHTeDxqAPFJzk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfzpWw6tMMWgX76cMZvorPRLPnpxmr1j2X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWQdAWwYPMFCRCAc2bDqjJoRx4crZVn1Vh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QScrEuDdqGHfixHcjyHFkbg5LdeyGexbkS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSfDW3KC9P5KQxBRYf4gjJUXSf1DZQwufm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZLFJLReUT98wGdaieoA8iLSY6e9pDtkuh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRgC1RbtDyvka7UH6RTqSNvJD8vTNkdsNv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfCfuAxSbNeHbF8Y2GNuFmJfmexqVH131K", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQDfTzNLz8NwmPJ1PTiL7zAtWdz7o3LQX7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdmfM8nzDfi6U22ze6kaEceED2sb2yYW4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYMpwvQHyny3zKM68SKFUPssSkoNwC5vZt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPbsQYN1rpJwV1GbPNJBUkCyx2YWPuLJZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSofobEjrtD2KntRYg5PLdFDdGuf3mdAyc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbShL174ecJPLU8nRSjtMwbrudCjzPRqFe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgMCRwAr5JoZvth1ESUo5n3Z9ycrfhCofo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMyhFtK7iNHUe98nzEXdkN6toAa2RttST5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh7LXNX79eJoFSUtdppQtAt7Si1R1wbaJX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWvVuQKy9165r1osQM98eUnAhfe2HiFEmN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbUzPNzbB1McDTWBDJdhpFsVUQhi1hP9NQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QggkvYpWRuqPjcMLLGG1R9ZXJAoE83xj2U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXS1p29dEQV1JtHj1Mv55SEWfDuHe47AX6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUfTY7fh8we4nYPVAXL2jsXSm3hRLGL3uc", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc6HfpXNWjeWQ1JsXRZScit9neymb3tsBV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3LcdQpecf8jMiYdMcs9pG7yQiaL4v3dK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiJC1E8sA1RVTuRXBFgqzY2zmfJ2eXMgtv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeFinpTR23Ryh8Xh2qeX9kHnezQniEx2JT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QevoCTEHo3PWAKMKgwjv2ziYdeWDJLXsUk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNsPF3iZ6RExncd7NCWHzAuofRD56nhP1J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQuFcmg7wsdHpEZjTXpbJAEmCxJaZpScfi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcgryWoPDdmNbJ7XXnFbhmXVpNopio26VQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjuVB3x6CcH8k6aoQfckdTHP2thnEkeeLM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe11ExJhNtsH55zAwEuE7RuHBdWhKNHVX6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb9d7XrcJEB94Lthk1mzTfm7gMt7XjVCPr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfA2r9SJogxx5h4Do1rMSEQuJCkeMhL37n", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUVbFEYn4SUz5eAdum1NHL9i3CvBkvdcpM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRDKcbobLECBD7yKCfzcaBAHM3DScRpccL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVTLdnvnJiF9r5P9aEYFobjj9Urv48iyJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhb9upVqQLzJfWGzALSVAZNwk7nnkGqctC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPMs22gnWYuCxeq133aQ8hezvfo2ukZjBV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYfxnLX6sxv2nKaemdR3UG7AFMfwSpWktA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeSb4PYhWYzrfvDF47EL8fEQ2tj89hszet", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiGDtjHbYSvCutyDP4FwB65AaMys26bgk6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMefivZuDRcohdW6fKbMUYozLpG3Q5Q6LM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMGfUDRXUU2ZFaZDkFgRvCADqf572WvXEU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTyeCwFefj24wSMwipWdcDNZonbCmUEExb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSfYaHUJcrSFy6DUF4TTdhdxw48A9mRE6m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTXbWbc63NFBBU6uT3f95htmVE5tamM5GN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW5yn7VbKkRLm5Aaowv3aKCja8VqqiGyCX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWPigqprwrci7LCZjuoXWkVnd195gQyBYS", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa6pGoGsm6zEYLaBjV85Nhd6p7aMbCUy9A", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNNrN2VtgfdGKSQJq3Z8AXuA9iMPddif3H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW9HHiZhURbqJVpjuwraujZPoDCsMPdjYS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRob3sEQHX7PNW9tJEd2iaXc2LuT8MFPhe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgUCStZkxC1b8AbTSDcEMTNj2txDKedN9z", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRJceDy9e5NEGKPZ3aEsKfQfpP5e97hvkE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZc9DBWrEADDnrnTV2DzGJvMJydgteH2YS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbgQ7mZH6JqNXno8rL89LqMoTsE7N3QKQa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ1TnT3hF7MHhSCWLSJ8TeZXFMDZD4FY7b", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYCyEsBMT6o53RTuFtmPUTJYDFsCEQbxAZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeYkt8Kc9zXS5s1FHGkW8iqZowABUJhgEd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSXhmKQBB33AoZvw3K8bzQeomcpDTSV8be", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVDMMeYvQxmHeTe8Nw2of4Z6AUm86Eyn3X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMrMeYPa1FPzQbH7F4hpsAxXGMi1cqhVwU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP3Vfwt5qAUW4JxBtCRbyY3qAYraLrJFcN", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc1pJ7rYLbUhTZXvdSvnD6JiKXrHHMSGa7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNU2RHKDRs5MVueLfZ5DyZQz2V89v197Cw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT5rNcBcKR6uHxXkwntscbxHuUpSqAkJ2Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTrJVPcMcisEfBBPqEiwy4UXHdQWWG59yo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMqVRK51WYgwCAXXHsVBw7zWom8LngFt5w", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfge3zy6Q1FeqKQfBB1ALqFqZfgZyWJ2Mz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTirUpjh93fmAjZa4Ax8PxwuTxAj5uWgug", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSwkodPybgHBaerTABByNBRnBeWT7oxUgD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaLM4cLhjYtex3JUXPzevefKhruWhL2AFU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUSryCrEDXRwv5iKZPDdhufa9WSP7NRr2J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbzqqkeRZtFDp1UCtsXByvkpWTVtShP8nn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfcpK6LtUNCdTjxkh3b2JLU5HWGim9utF3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiWFSfVYCBdTJLDNDnZSHwqKf7Wrymw4y1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfgvJceEk3UMkQeFc5h7n2V2zhNuanGqC3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc2gx4tsFiJSea3jYUfrGyQJWkpZfZ3FfX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjogZjZwQHrXeDsguP1AMW8o6ehcYNX1h1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ8i9kKbWni9L1ZQf37vjfL9wdRqQYMjt4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRyMhM2WPk2yg8GDRCHzGZzgqK6a3QXUtA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRLvMoLehvw9gK7w4HW6nUn7EGk1F83Ekv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVqvXtRsofKyXjwXieiqEpwRrN3cykue2z", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjr86JYPa2ge6eRxvCbuorhQ7Qvf3T7fve", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfdrQRjpYvMo5FgctABoBZA1accY9GpnGo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMJnn9QY3ZwuGesxrwjQu5CdoirQ634HmM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd5aSucZtsGUpkk1A4nk6VHKHLN7SQ6bsM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNmLqbetUxdMgzvMBr5fFgVuxrMuKvdRca", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTNphesPV41FeTqzBpR7qQgz1k6WjVLkfq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkq9TSQbn6Tbf1dyUMmuZE7Dgk9EKi638", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeBSM5kEQdcVfA5xB2wyWX7sJiHhm1eQxj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUUFvC2LtMGMoDoQmBjG1fVGhfauFQcg3x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmctuu3wzZ1ySvPANMRHtcR2WqzGDiuLM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMVtRvd3r3hRLSy1xsj8q53kE1PfqyJqJ8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVeC4LsXkUvd67okfGXdYXHsaq91TEMzda", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQXPGZnC3BPZ5ApnQvfTZfXYaXsZZNzzxV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa9SdiUgkGU8xxCLYF9W6D4XtWpagRk2p4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVsJzxKLR3StSb55GQEBKRLDUhWQvdu4mW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgoqYLgYjAg9Sovw3UrwNZr1uYLbdZBKjo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN8LhGeJiDjidBNUwrRjyXrZW282RDin9J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhR3ygFfHKr4MyUj2b5bBkowgCND8RqMJ9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiQwmW6cybhHYSrDfM2DYyJeCQJMJ7dzG9", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg3sfP7bu2StVvDxELCZyEFMcCZ19pwSnp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXzGnAFwuwN3uqztJ1ARPk8AkSCRKWddrY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeLPwH4xD5CRx5wMJ3zU52P1yPw35GL95v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdgbvSACGz5uWTjMBcC5MBMRi6gAU4xBg7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQgyrjSUxb1gGoG6qiteuuqfRTPVQxHw4q", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbc1f6SL5AdKmg2xxTcuswEe7FP8Kv241g", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZMqoWSLEtTz3rDAiuPigkgpdwbGqFeA4Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXkrrTfHCdqhHodX5ZYmR4pZ99bykeFqKe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjFKeSTEdusbqF2S4xFQURKsHMy6m6QjbK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY9U7czTSvqgi77fRhuuwmVrWBZYxCqzQ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXAyhHovPqEDmdUgRtjnrC6UZMVWE9P9qS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUJxJyYzxgukWBNc4Aghs67DaWoN5UFFn5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdpYgDsun4TwoNjz1ZDsyed7GEGXchNw8f", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZeYrnArdbNUW8bgLxaJuWRyXRmrueor9a", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYYocaRuwxoZzv1JWr8egZkGZVgNAkd7o5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNT3JwAF2cQ3CUCfX52x4WFGgksH4731wA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLsdr3KVacCYGuufGkyNerzHgCyNS9EBiw", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3mCSgWqMECNaSWUDnXbz3a6sQ5SRdXb9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYjxtRJXiDHRaP3urEd8MX5nUV9fbgb8Gq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjgNuzEGvBEotvHo7xynD3h31mntp7PnSs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ6Ur9DWGZVzzppkWcZupGAbU6jND8mN2A", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZsDt43LLsYoif7KSHmyUXcUxhWgQfz51E", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qezvnvta62kW8ZNdiio3h3Eded7sDG89ao", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcBuTqxNmsg3QotEnW8ZCf1EyWHwqBc3w5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT94EE7rzSgazh15xpzhjhuqKFE88cHHgY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcBqZ4ozxs6JPcvCT3beYzki5Na8pwiEPt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZo9xY1NqYwr8XxoiNBVHicHsQDRPDvanM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU6DVbLkztW8oS1Q17j8QEcxisSbxnTZzf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgqdeTtYTKnLAoCH5x3mh8EL4bRixSAoB5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhyRtUUohmkbDzSjZw422cLeXBUBK1Rygw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QecZdcfkyFbKqTXGn8i5s1iG7Rfz6mAtAS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeVSYP9juB5gfwL9QMz3NgYgNj1FLJ9u2x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXVoRnk8DKFU6AjqPAcx3RwDnzDnknxwf5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcc3iyh3ektfySjbxgJbQ2g457k7KdF2hH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbLxPwNiMmdaRPYywjuMeu98RDAYaZPXQp", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi2DAZBWbia4KeE52Qt1PVvzuSEAHQAmyh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgFyAZ2mUp1879ZNpKb8zHFCsYDnHhVCmR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QawWJdQGTNHk9VQUwF617GRCBpk2zL3Q7m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiYaG6TMPjtqQwFz1KeWp2ZX86JCJtaDcp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNvmjT2ZpBSL66SqSEUPPmPK7pddcxauub", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMJshnWZZsr7NRTuJuwHY24UKMHkGorRqU", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgu8a7dGNaMLudiF7LAKGA33BSzEa3Jdwm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbrtqmUoEDdLiwnCWtvNwXaccaSpCKo8uS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQPwgGCF3Bp28VBiDWFku42wDYpf1sMxQe", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd2iY8utUL8wcshE5MCfBR9SVBmKbyHU4F", "amount": 10 }, + { "type": "GENESIS", "recipient": "QamEfYzNmdo1BEzSbfQSqqSrHbA9AJCaeW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcQ6gNFN3b8uHEhCuG9sSgk9LeXjaHKF8f", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNtqkq5KtkKKF1jYQ3GaNFHALANh1gZ1Qt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaWzzJ5XGtKefyCvZ4wCMW56JnJpL8XWYs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfeFfrbAL1pxC5jZSUum1BYnbToo4u5EhW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaRd3tTjcroAPYXvYR8zmojcXPHL9DZxd5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeq1BV4i6gN69DmQ9AgkaPmizo17YuGKA6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhcc4R9wJ6mbxB8jCgA7gxsonqGaex7hq1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfSa6ivpmWjcTZKw5Mz7sLKX4S6NgPFrFU", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3LntnKWfJLkkVcJRqkRqSzqjcJZLrCoa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5P7zuFKFineYRY4Wej2USv2A38GVDbZv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbB1tCEKriy5wRnEVetWZmByjYLUyFkg4g", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXBXg6c1jNYZ9PeAKGLsBiuMY9MVyYVNgz", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfp2xKR2hiWS29oy8GYJgRANCQyHsSzXMf", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj8yVKdxvUxBe4E9TvvKcjZ2UxUpa68ZP1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZTGHpZ5cyqGBBpiHMTPSGngmqgmh5LB2b", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR5SGcLtFAxk6mAQZiAMMRUyZLovDaQnQf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcTPM3qZFXsArex2Tcjq8KzJmZeTL6LG6A", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi5o9RHLN8menSyT9ATAv3A8ge3vu94KGM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNNRCNBotwc4Z4dyYTwhdCz28EBPHUqgng", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ5xduv5rwt4f54jicU5KB6TkNZQJZDgRp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQrvmCEHwQR5dzvLTxy8edXBzHJ9Uwde1W", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbi3FU8dMLEZHJT7DdZWu5rpXnWT2GTGF9", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi5PGoa9H5zBmfva62SgbyJ5bo2qYo2uKG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUpM5bugMgiZ4AqDiT4aiy6mLJQ7Y9GeRU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQJJE79CuahqQHSJ4xVVcxANHfE1YHMUoi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaddFd123JhgyyZo4SqDzRxkD4v7wyfDxu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbuBHgF86E1WHKtiGswiGpWZxtFRg7L7z4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdDqQ6rBJLrW1DhPuQnw2Nh2pLbHoXB38k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNtb3aiA92Gd9egNvhK7a7uwZY1tDHVSCy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPV8fxpCPPqv972Pn77hR735rQ1h6dzAue", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb2ampydMe4iTvTfh7jtuUbcAuH1xJUpHm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWEGkXDJvwyjHppad4JVvCa6jvttn7aPJN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPHTv62t8XcdkRjnzU6qmN3yqi95o4F4An", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY5NwDSwvBFNhu7M2WxUDvyvDPmExQXryz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiA6aEE1mq9PPkNTAU55crqkuHycdS1Kf3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeabFZdH5srqgfjN9rACGbqkSLdnPHc5Ym", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhKHv48KUL4spnjx8JppAdraah368VHa3D", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRC7iB3Ce2vwSfFexT2gipP5VfFBkzYG3K", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaHF7gJzo1i4yqFqp85QxoQ7WGRuzhm9kL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNxE3CV5AMfqgpKUrLWPYkVjWeJj8FGvZL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbBvBr2gheZkKiR1nJNfzhA17rnFpPeiXr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUe1jYckxbSTYddnQDqa93xJh1Q13pbgwi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaKWS4aJHWPee1mGLK4NKfYsHoLym4qcT3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWU1HEMTbvMKMgjVmRN91ooaAi2TX45XzQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQxVZ98CxWA79KWer8tBtgbbZ5vbdRfTuu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNDHBHKpVz4Lr3EBDkSJ4ZoiSxG34VjTMH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY4E9pEXcEFH3Eh8KL4vuXZZEQMsCRjJLw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSdyWsbqYkFupwWdxt9AbiQoP4cq9ymPgX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhR7nJGFMV9bhj34Ldb9SYiTLMiJWnA2N2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVCFSYhWMLTCmPj6mDnLq8JQ9fDTaPDCDY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVp2aMAFAjcFQe7Mev2XrxsTCYUcbGfsZx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTred1oVKR9QSeuzZ6BudnkK4EUsojwHsb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS51FHhmDJHrJ5jxDVTPXbxe27hoU3aJ7k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QerB77uXd93h64KuMXT1TGuDinYGyBz7Vp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUBx7ioCLLbFuMdYCtxmxLpiG4EoBCtxDF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYexbcwSivr8tvr8K7P5vkWV6wU2Up211G", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVY6jD33ykTCVLjwaL3bnuUupzSVKnLVyc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTDSpCk1BwrfUhFrnJb5jo4u5ce9mYrQtq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWKFaTrMDsBrWB2fbD2GZ5j2y8mt9ofmqN", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj4Y13T7YnRRnZoDEQcSvPHgDz6dHPFzUH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMr2cySkP9ACj9T3pzhSZkPsCeasiLTuuF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPocRpr4MzdpHRfjXDdp1PAbjDVBKMCEax", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP48VJk4UK3XSafgV6b3dLmsJfnDvNX5pD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS8SYFNeDzyiNRL4tJBLQauGMBXkATgFHE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdfAyJ2fGxnzmyXR3J5ekG1LbjD2nhUJZf", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qah57SitxbUZDeAiCFj26k4hvNFjX5cQSJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfRZsM88kdbi8a26SmrZdusR4pVTCLCHmd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgtS5U8K89Ax2mmc2JKCWBHQNVZ7tLwCJn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVgD467m4gCe8y25X14xsMchFvzbeNMay3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWScc4dvcbgPmAQSAUZsfpqCRPE3nivGsU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUBhMg3yy4FtiW6h5136CfQqqHDxr3SUtg", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj54QTsNtZ2HtTt7tPaKMVZdSQtfdNbrGL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVX7DzY5oCJydHWdmEuZMpuAFLpVYwmHzK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLi4GUiww4bKQH6ouEFFEmyHMXgPDvtko1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiEUfeoo8eAKUgFad1qsMziJWw6ZenUxMd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMGeYbe4aXs6CTnstGib3zZd7k6UvTvZsr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWeYhaXNW94WAY1YPm83pXaZfak46AWaKe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZMUskZQiycMLrcCmRAE1xDDLCyTCAVZrf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfVRtTXq5ft8L8CA6XpUKYK7v1Zea8WJvi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeWSpRvWQ5fW4Deac9fhy2KogSYJzrFyKf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTnFhyTywaTZcxQsHuKYXoT4x5DMJ6zM7u", "amount": 10 }, + { "type": "GENESIS", "recipient": "QepQfSL7yZQAKFxsbpnqxiWc12FnrC7jtv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNSGkxLdJwqztSbHRP1a9FV1o48YkYAgGy", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgnt93E7MQRUmisXR8anK81D9SdmCxBVob", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbfLajkHMLZxNTcK2p5B5AKJhVbWSYohog", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qce4cfhZcTbV6FyAfGfzwpP58qpeDF1Cci", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ8wsBG98Q7HxCwvCUUVfdXo9CX3PcEpTX", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa6dNeXGkMdooTd8SxFicZYxbxPGCwLx8s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMU5izcUpRNk8CRzy7VL6CuP1DS4XYnNeP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QibWhLgP23xahRe4cDQ8JmdSavEA2RAbH9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWn5gL9aBWNArVF4e4MRgP8YkUKee39W2y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfgiHy7s2jPJFe6zvHQTcZWwV8ojLyKvrs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQvXhppxwTDQrhs58Gb51BM3aUrFevPH5j", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbNB37Qtoh2i8Pj6MtzGANVUZerzG3Zb2N", "amount": 10 }, + { "type": "GENESIS", "recipient": "QabS4XAyJpXzPHZyiuUhurnpuHZpACNuny", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkuFWrxs2Rhwt9KuhVghX3CAhcSXTmN7W", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSrPzymXJwqDbDpmEi14pRjtrrdehZpGyc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QexNnLLCdxJjci43j1FytfzoaDD5RmvoXE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMf8KFSxAsyTdGrNQnFdXQkE2fcQrrVWQ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVfMdKX45x5FdnRTdKAURPCkymYJRyJgRX", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdo67QsSrDhf4oL8D5jC2efGgUknAKrEWK", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcb5niZq3fapBq6YHcSFmpPdK7zKAyVqMo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSwnihvcSfvePUZJUKZPuTr2WQb5BiyW1D", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgjEEYEAPWSwS4jEVZcYdJSvLfwoywCyvZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbyC9ue1BEDbSafF4u9EuhuBvpMvm8rTAq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUKzxTDD9AzthoukedkqSYDEHRTFjRFnfm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhTn6DXvAatHbqcz32NJ6Am8nyNDc2ZMQM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTZHY3vX6aLVGWe8A9QjW3uHMGhd1pMAPa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUmfeSj9Ae9NsESzHKsgyF5i7sw3riWbCJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTc149rVuzoJ2kLLDi6TLQ5QQfU45B4VFk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfX1sfG3Z1ix5mm2mdVDkEr7fTnq5HYRCW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfMeaYEra3ZP4576eWgBYwyHX9gbRcHE8x", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg65672zycKy7Tb5SZYxXPNBvh3vPwdKdy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVF8uodXcVdnvU7DbRg4FJBR6dfYNK1vSa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiA92XRA1Sf28iAsrQvZNsYTDJpUAsyZCc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfEuD1CtcefSu7jMYpwDhZHupBwmhaCTsz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QacH68BmZyMkB8dufjzTjGWMYkvUHUwFut", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb1KRviQaL1j93c7CWb36KS6pvfQdUSLzk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbUuHMMxwbbnRZZCNRTcSK4gZ5fSha55Ed", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdGQppMtF7LKj5uNBCQU5LQzvwiwXeP9Uy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVt2cpiE8HLsofz1iEyFcrJg9g7MbGQZTk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPc8yZZztKDmF8SCKBKHcMVEXqyWypmaoU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbjPhMwdXdkFY1sPrE7jMWeWBcSRTZweN6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgc3Q1ZRWc1LKX51GqPtaYzXyLn6SyoobB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS3AQ7DD1RZ6M81XcGdrKibhNgvKFnNwjY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdGfF78c8kwmGFD7DWhtZMGvxG35nT7tZW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVdh3tLLkAZdYKPAMz4CGaSqp7RvRmE1wc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRqjcSqiGWADnD8Z6cF2949PYWwRAsdWd5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVfxu5mgUkfiUjdwxvnxBJEsZuUaL2nM6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQR6WaNVF8y72Extb6Ndb6bqEDabCUiXs3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhim4KT9VkcxbE6a61ECZ4nq5dHUtb9okx", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh5GLNHyNt9Zx4umjoBkbsaPViJ8xKiVDi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV1FEkzd4nsDZPddG3sWBdxWCELMkZ6HFk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfrwyMsGvF9Vo6SMyPyKSuveEFikx5fgvc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ2rRvcqCr6nj5kkxwBDT9ZTfN2akMGv1z", "amount": 10 }, + { "type": "GENESIS", "recipient": "QepQTxuer8cnS69aYf7EDAoWQw6GMPLibW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbdB5TN8P2mDRrBi7kXWu6U8vNkMyh7RJ6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjEsV1pxjcHPNPV8m3oCC163w6t9PZZF6p", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc7yMoMRrA2fXmQ77JuxSfVdXyfPdcnNwp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUbAAYiv8P1oACxGDp4jGWD66t7siiqTtp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQsYuc5XBWaUsoR2QAVs4AVKBmY9FCSrQ4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXo7EkKEDCE1SeReojKyqVUFVQ1sriN1WH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaAo87qyhKXU26y1YR1FTvrLHv2uav8KsE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNJT34EthEvwgonu2vUVHNmosGRRxZhSHh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhTQDuBcjfnhqHu8mfRJAYn6VyFj7YjrHP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQpoQWccsb6UVWnstdZzyMZZjBuWLxSgaV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWQ6MUMNQKiJFnF2iKsFakeVvH4TBogxki", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRXZA3wdXpu8phg8KJFe7RNQhs8D2P3DLq", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd5WLbqdcUcbi5ZyY1rsDTBpBG7X6YAS9r", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYAk7XFu2bG5cKTVApjey95YRtie4Ed13N", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbPqWcpXcNGuGhYZ2hvLNQ6XhyfudvCbi6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qajx9YjYHukNF2fxq2UbGniGdpQL6jzy5t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiPKz6cwzu6HiWU7ayBjPhW9i63f93K2Gk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPmCwzYeToHypmcosysxkSu2hnzEPkZ3Kq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfFs5G1TPzDzsa4UUB5PmypRnEFTyS3674", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT5Te5Ya15tV3vSmdy2pPpZdrnztAAZeUL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeiMYN7pcPJY5GUvZo2tYMHvDvRYx1cNak", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY4NuorvFU9AUhonC5owihgNdRork8oo1E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSDWB7bKAoH5sHRVsUNmTPe9xDkvX2phom", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNBQQ29UH2SA97MLDdnTy7ExxZxLLpfZwU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhP12VMSCpC4PcV55Fx4aFfT2c6RSsMs42", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgS64v2deiY1Z1AiLkrRxQKzJSMCNXVgrD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ2vjwzV4Y5JCGzkwJPDgWysNMB6rFVgrK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmWNDLYdKkDwy5kRbyRe654wksS8r2nUX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcwaxY5RRmoa3fSntzJXZLrwLmfrjqtFNu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaoJPWKxmGRq4rWNfo4232yVX5WPBuoKqC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSJHRs8N3dbPwYbhbj1L8jFWBzrq7L3duY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWRzfuzym3kfZzuoA5ASpnEvmgeHE18hF6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQUUAxnYkmMPs9WWQcgjwUMVPGpKnQPeYc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYqZN2qwT4zfE8XkAfTnvpQV4ws3JbCMxU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSx29CwBhQsJbQ9hVQoAFEXQR2VYz7KjMK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRnJfCXxGrdEUDVdHDCw9DDV3gKgRu5vVQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgWKwKe9mZLgTu2NeyeDsfuPVE9Ku4Zt2s", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcj8jG5E9KtEYK12hVmWdo6cdUKven9z7f", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUMJtgeL4xEBWBT4NZdjqvMWGyfdagQ2pB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdPxohH7LJTdUSXXnTb99qhuMSqJFCxc3s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbW5AkBDfr1cLZHtMFANoMKB9ta86CAYD1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPgKTyPyj8DMv2nLZumJYYYwSD7iF3Lw3U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLzWPFyLvezHjzdwnNR5n1jUHpHjdjQ3R7", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgcf647FFAFZ1JP7bEv4sa5rw4qr54uTQW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjuQLSjRyDTVhDgMxzUjLJFbnYdUeXyH23", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbwcDH8PDbr5Kyr5jwBZ9Ys7hzg1A5QpMA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQfhecXaev1FYq2UgMhpzZa4oayc9k1nnQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfpiA1rowLYMDVPf6oe7E9R7WNGQwAKfir", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQCcSHJspqeYhfxbK8UH1UhjHeGzmnQEHQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPUtaNb6ANbWHLJCGMs1o74yeb6pYmHNcG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYrLYW646AtMjd6Nn3e4qzeKkMCzhtBkG9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNfyujwtGFucVnjkaDEhdRprnixYfV2wz8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSFxD72vCMra8P9ohh895NuuPHvof9b7qc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbVAUHJsqRY9JNn9aBV8VEowQQ2BbP9uyv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXtM9SWWqqJGS36qDq6S4MnMt3dnUb4kNp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQCr9Aj6XtEVQXbz4D9fHSDDwn8ANbQd7B", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSqyNKEktA4iXb7cWWTSUMkkc58vCiCJCH", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeek2544Smo4zkMHvbQ2tVJhKv1gDAp1if", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQUm5WQs1jzN19X7Ls9NY5q9G1BmtbKK3U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QenuGzurgCPaeh9xDxRwoPRjNivgX6h68s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QihN1use3mN5BshhSrSS3hF1iMmwPFcdog", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbu1Si8WLZeXpwiHXzPsSkdBMDV1BFLkEU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRkubxXBe8ABtsWFpdB498EhBy16FNiPMo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVa9VbF2aGbXNh3LfNxnFJ9p8cqSmFnymi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYqQHwpJMPR8aa4PWKEXxmc8uB2AybcRt5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPHyxSLBx92izt17oifcsBqh2WYDTWcgpo", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg3QXvRPXayBGqsGzfvS1a1Eh1WDHowBLM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjJu5DiC3xVkFca2wznFCei4HBbvCRPoJS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbcDt7uDJok9ka4FaVtXaT7LYR1sQMENyL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPR3NwdDuZuZGXW1UZjoZhHKWreLw7iZVi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaoWao3UJjZpwbwj6YgrdWgS1dDvR2vEFK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVX7YLBES6rJGtTeLespEspCxi9oDYxGQ4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRpsUCr13shNTDo78B3r4UthkXa3E5FgFr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QREUmhn4Pty6mjnJxJH7RxnrwN1RvbD7fe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPdzGiWdyHjbBhtCMvpd6QacfozzMPpfNa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMriaRPNU6RZJmipSuZcRi1WVj63wb6GrL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiW6Kd22LCJjCpp5EBotFDeCKjCC7t8vSY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN5GSskzBjKQ7ZnwMMqgko1M3KWKCVqwh8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWwra8uA9M4pvabK41561mgFd2o79thQdT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcQSX8HPRpNDGgQH41U1QV5FJ1TgK9q5Fr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcTrX5Qzbe2djro29T3wKDq9MA8m86HqUH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWpLWkuZ2pMiiPXRM4jupQe3vBp7GiRtvA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdLHqfBmfKG7mnXCUALfgQvKW4S5igrDSV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTBiyoSuy3ZF4yLJzjqUY2imVPsUULFbG1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTEFHfLoXohdbT1FFdHVJeEL22qmMypTJ7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdRwRvbTfT43sHjyG7q4f38PaGvwiDyrWj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QijECkb9URmgXD1oAtvYEe59dPmU4A4fHP", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeh73yn3ngvB5yX3beKJArFuCJks5i5r7B", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNWzHrRGoZtYhEUznjdxCmMi22LqGa1ndN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgNjGNiAKViM1Mzd9pGHieNJk6CRSFrZKt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVrGMZ8NBLEvEcWXfKkCWXDvUCWF4z4yXC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3camytysJJ2BnfqGmw7BUegZXJTvkeeJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfVUkFvVNPxQCKgRFWzFQVk6oFCbuyzyRW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP86kJ98hxBi6rzAJFkoCuwkQXh3DvAGcw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXxMKghXxEKx8RopT2rdiCrBFvoyN1mZMS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdtUw5FRmaKAfJ1Ttu4bUagfX13cTHCpFw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXuHgMUN9FWFVFjbquFqkDw5NKRToVd2t8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjSZxZZJ2MRB3118i1VmSuzamBJNCnUFaR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUWURY2qbSM29to4uuZ1CQXh2VgWp5AJsT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXbg7o4ufCFjeA5uSWDSMB28vAc9XeRSH3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdmf9fZDUFWxjXZU2hrhTZmKiiMuy6AEyC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWJgQDygXYBXuweFXPTzte1eDMg3CnRUxS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgZJ17ZXdrJEqcAPM4Bnj3NJimpCupDs2x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiZRd7wFASi2jaQfiWSMFS8Qcfrp3MCDxo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZuxbXpcWbPNHoU4yEps383E4rTKkkTdBH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUigfC2QABH3RMuStStggx9YiZ49VdtWTw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVQepwPCzSosioi85mzfCxVMPR3f8mGBjP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWBTMtHSCRrHTabkzf38tqhe5xSB8wtTN4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRY5mhkq1fV9MZ8rtrR1j3MnCidqfstKCX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTnnKbBSqDGHKiD1Qo7yb8ry33mzxZDs4E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTtyKRb2fSeuhH44cvenzahXSKWiGfV5K5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSbg4JJCm9oZkESD9obePZpGK49gWbmGsc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRtARSe5ppL7WpNaMaWeboWbVcL4Ua3nxo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaQs8c2ccbjPGhpAYdaJRLGyBTWTkDKfmh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRVotWtKboC5APg8YxhjdJuV9JioWFydiC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjXB8Mac8ityiVMWHkXPbi7qgKMuCjKdbW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXLtbT3ru6WfPjTMZ35q2f29kuNh5v3X8s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QisDJUYKUnv4sEW3RfVhNywxVWcHFg21Rq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVpbg3oMF4MAUD8QVQbSfK49YfUYAijEPf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVYPrrnPsn3D3AbiXsCk6wb3EERhTQbauT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdjJKpJt95jGgyQK3HR4qTYQuwAYdYTM5X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcUths9zcKzhmWxpQjdoPkf7ZCrvPqqHum", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfErd2q9pvzPGuoH1NRSUgXxZtz2oyWX6C", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg8zWrXTiAk2r1gFLrh8e2vSen7DdbYU6X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNMF4gGKyQxKBHxC9weivsiGwJ8JFAswgi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWNik2tj86KQh5zGCoskz4Rhcd9K1Qv2gL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdxqwnjHC2qy1j11YMZP9KF9dm8AbyGMby", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVYtX9qPuCxejLZtUxabTJ4urKFMcbwsb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj6ykh2hXy5jiYRgrmt7D4H2KvMX5oPWac", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNyT6h7qK1zS6GeqXfoMJUf15pyush94yg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdnRL1yDRGhoE2695SCLpPdCzzp5xZLMDc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX8FkJYYLTjXSwdJBAwHtTvHiZWCmAVmCY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXqHgue5R4qJNvPEsxZvbYMpsCRmD7YmRf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTUArhyERXNN76q33wdcxJzVxZoo4YUQk8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZHKbDYdSjS6FF3Mz41xowpjHF3fh6BvFb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf73yQjcdH2hFndu5f7xcb6NDt19TP9DoB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMxU2uvzikxgzj53sE7cTe6iri94z4FuXv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX2gQw3y5xuKD3shthn4cQ2mZ8b6XLysjk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNtoFtG7gBXeocAGwDVvC2JX81qs6gSPHU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUHZiok3byKWVjdp1U1LcVhsqcF5ARHT1q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMZ7Vnq9P9TcB6LK4WLZstuf7ozSUBJSTQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPy3imHu9bFEkNPF28vidDQTZtLGdgpWqC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWMoMkXwPv6f5s8PxRfiy6u3nYfVMpyGve", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRYAgyuRWrLVg7VaB87vBVWm7kUyYFJ71w", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX6PwuE3VRToyWd1Y5jiUsByFvppDeha2U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfoPk8G5fiBXJ2S4Yk8PpcktDj8AZnShvT", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdihq99B7ZnAtqru71PAGQhjhjtAJdAX5k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTGMkcxHVmxv1JkDw8DSWtdB19hTJLG7zd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXgeaEWieLL93jvT6QigYr9JGcJdnFXByP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSSKJUY33kdbz1vkiEooiY45VKiZkD2Dka", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg7TbPrg1q2ydVdNLqJoQtC3RLBUo3t2uD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgcVr77TfcVmb9iSgsSRPeQqejqfKAvgQy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYs3NV1EsGc2GaBYC1jPAPBsZRGfYfwopn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjDgJmv1gnz3VSJHziY3quBHE51qEAj9b2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMoUYGYfYXdVUxAGvxkisHfgzwvf1psMLB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNTdKEnSyyUFfp9SPnbUSgbbnHi73LZ4py", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf4NBSfFKmjQBuuL3ti4xUFLt9cutKrHDw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfjrwPMPQTdkc7rdu1qyUGGmy8uyXB5BH3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPxbzCsTxCsafRUWp1oBHfbqRva6sHyxTk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSKnXn18fM83HS4J96BvSHmYi5CfvZYgWn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN2yfuEHpZqZDZREXUUTp7JDzzAnzD26S5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjCL939qc9yuNuP7KvEnpX3Ykj6vWtU5Xi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkmZBSS6CqiUZrL6HkgL6NeEAbz4VYgKt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVRxJWg5jsbNcuMFSzEbrQ6ZCWL9qqiQBc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSKeBHV5ndQNEwZf7BMT8YMY63wJPXHUDg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM6nHqVPpe9eXvpeshH3fzKS7vok9ykN2c", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ5HVfecMcnxnUbxhNPk1HV2GMUTqUF5uB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNDN31DNB3cu6E6hKT7YQRv5P5wzbvm8gR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaDVaEtetDZk2SQUcEwrv7srTKVi5nKMXk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcnYqcsiJ5bJnyKGMHRQA3LjB8EP6kbRxs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVg5c7fQ7AjQx3Vtf1esfbNeMjuJ7HSxnS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMG7Q4CQfS8uWRFVNZCkMq9EMeKQMyo8hA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWeVdu8Q6UtPCA7oxcw1vN8V4BYJ2UTLuT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZNRfr4Q2M3GCgUiCrffn4rr1fcNLMLuDo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXhvKsfnptbBhkbyThihqZyU9QESPfATbP", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeho5fUhWEb58qFoZdMB9LggSnbaQh9vRs", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe8DdBX1a6dzMyX6kA7BXHmJz3hPWB1y7X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSAejnaEvm4pSS8oXEh3b9XYqmKuHhqLVb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhidz4HLjm1kVrLTd8EPyJEELnoFVqnATQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRtmedUHNdfwaNwBWX8tAK88mwTWa2z8Fe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQa4sXFp5jq7ntE25pvz7xVU3rUWs4eiiQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSz9ksffuikfBjBwFRQJW51wQ5CbSc8HUV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVHXvLjCrNXgcRu5nw2KFNsaZ4SUkcod64", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWnXShDuAWiCmmKsLtRUWrbovhoXafU5sP", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3PMPgJbk5nYi9wZxpyoNwNPaPAjBfKa5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbG8iHnYMCEt5Gv4gbXT2sTiafwnwy6SWh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTkzizg6HmDCNjA2XoUSJK79dgYTAFdNEg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfmqayQUFTY8YTqrw8odoQ8P3RwyWo777F", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWNkvkThhZXJQ23AidJ26bUQXiNCNfeNMv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmJ1XpdQSmZLG4vDS8EoB5w6UrwYzUFNC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQT6fRGwyxAS1uuayVJvetBHBhdKpBvgFt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWbqaN8TxsvxDihfkUUBRobujVbxsbzoTA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdtRTY7sfe2xQKR4jFRWMpFyyP4EoPnvDJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgyC28vh1ri8U1UCkjwQuCinjJS6xmLE11", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLnMwxfVJf82MovsG4i5GPvkny5JNBTQup", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYF6MLarj9k1VPKyog2YHDBboeFngmUnTK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeXgozDEv5NhxmNzbV1HEugcceLoye2b2y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLrGfcLXyTWmA8CPUZkPM3WywzTAHVuz7x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcfiV9f1vUbBLrTRPLJsyhVgKzKT7uuHKi", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgxgpn2J8c5LzC2aUkPqixnVkRmd4fjBUm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUmr9efCkGUt1qMNer2vt1xtcy8S9wTtAL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QienqdWpCiDvk5q99F8pt1JYTZsSn6qLrD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUMip9ykZ66AP3Gbg8pGP1ewoZwoTZBtba", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgYWsGqKjL7MrJdQmsHXMhtKxJqW6vWyTw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQMiJaCGrw57PsF4hWmqtnbmyWVLPkq1s7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPSfVkCtF3NJYyhPNN8yNAQY8pgRbFirgW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNrY4iAmR4TQtB77hMrM3u2XXYX2st3wxD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgCQoTy5Y5RNrBjeycXthX7t5HX7oEzz19", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiczLg5bJZsut7zqwka8E7y9Hi6qPh4Jqv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQW5QPRbWBFQpdPa9x9x8AxejhgSTUGTwJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTEDEPGWU1pED5VMo6dPYrN9a7CQe1zWtS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZDN32a1tDV1mZ2jMZekaHiQq8QTfoaJ6a", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRBJVtRZGb99SosM9y5YJ7ogsMdVxXdPu9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QifaDahrcETU3Jc5HEQJVUQdSXVvRYXUTi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaXsvTkVCcfXYBod3LLnT4yBbVyxAcSK6V", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJq4Q8ie25z7QdfzeXoSJYqkG4pYQDU6J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdUDcf7Ey61TxGtfdW8BLTZjBJ7zKGgk9s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgwhdGRUuSKm4xqpT61xB5iiP29wKFkTXr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSftyUsD8B3F5nkW2YjEikmcUvLoGHjUL1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP7NckFrHLgGKbM8aNYwbGCk4YjsmgeKT2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYwvyiFoaoK74dw54N2xt7UmWH7hwUeCzb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdssSgnCVg8M66bacZqFaYCDXRGCpb3ze9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQTrDiZhETEoAimcJFfFT63rzqBy6RNA34", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfi42mfbpxRE2KnqH4TGQzX5dEuSGaTABT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRYgWAEXBJ21AN8ncvWYN1NhQm4iQV1n6m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYu45h5kp5TAx5R53mMk7XUE1YgkEym2H8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVX3JsK9vcyLBLjoWY4WwDLF3MoL3tSDMk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMHcurZGnyzPAmdNurcacm1GNCUHRRZ8jf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVkpbCGEwfdkEjkkXPjZJGGqPG4F5YxoD7", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3Fdad5SPJuMFFAmndBRe9AGumWxvJmZr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRJjhxgFEepMD1Mb3Bzgmd1t2WuSRKxrge", "amount": 10 }, + { "type": "GENESIS", "recipient": "QifSzfbmba5KHi2HyUwdm5C9evXqXENgZk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbs49mBzkHtoKouBUAD9atYUz6RBH9pQ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcBREkh5tkffuFLT78SLPJZCzVWXhnweoR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbPcRUYCLY1WssipaRygKeEBm1LHBPZnuR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfypvmTWiTHo2GgpBA9CrGvr9ke5Pi1dvG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUEviSVKeHCwfBm2cpVHzx5aV4uETjARNh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdhGpQJJDuVbZrVdNUS2ec3gNq2D4Tu9pF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZEKMgog8epbcSKGaH3stvFX6mc6EH611J", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qiq3xFZgSv8hiTmMs2inxf5T5tDfarPU4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZSS1LoXwHpPNzgW6schoQgUNoKoCA3iVF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXaaXiBDAiL5nwVsPhGwabjoEaV11q3DzG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSUBoFU5hhcHduACBiz7kD4UAf8jo8zsTb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qani4X4UGeXamvzHc8X4RXzA8jWSmH8cAW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSTzv5G8YEhtHpGoUDNFVW9LMNke77kLye", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNneGgUVdTAMkQ9hoY1XbezGZ4joa3Thnp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcNxuwspRac1sGRjotUTZrsNAX5rYr9fM1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTuZoN8Rcm4pLUNP6HXR1t3tU9Z4Jwiu2T", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdvieSs2Lnr8j76TMaZVwiN26kTzsF7mFD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdSppgA8ZA4ojEPdNNj9akBbgDPvnTQH8A", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgSLWX4QEgujL8vB1btx2feZa7Nyueyv8k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSoV8SFqxoEweZ1rJSsWtM5wJJnrbT2LGH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPpr5vJcjoJY8f7Wv4wrQMAyfPx4eB9Kk6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjHyEgbcJaYmmABWCMTcDiQAHsmYZ2ZkMQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QadBi4yLoKjC6XHKGmrVJsVZReR7PzHgmo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcAkNCK6bF9sjZYsroSAwRRygVrq5Lqjbg", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb2vVR5Jq6AmCDXhZutKdLU6fKi8weGPzT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcgtjvYfx4BnV1mmA5FXWtXavXWkqJaYXo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTK2wbbs3LzideTS2UpXLwKduVaFg3aZ6h", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhRPSTf9z5nELnH5otVyyRN2iHLJGM5gxH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPqdmoZnmPKCwugnbtURKgVMv4LCN51Mra", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhz2Enzj62kVerEnscLa1oCmJXYRk1b9rY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY37v4nnj2JdnwxvZRyQKok89PkXNy2DRG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXZrPLnA2yjCrbFkk1TJ4rGVunXDTcUCiH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWae72VAzeus4aVbUYJmtqgPhAYvEWrrAD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRzCnx48eJtqm6gUKhdSEZPVE4PoD9cezH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYFZoo4jcSDgrPxQ7rc8FhPb8fcNgBvrhu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbh5t9UiQGeR12cTMaEreo8pBQCEUodwm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPiMqnsBRRwGQFJgzNK51siFqUGppfH1cY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QURar6EGNcaXr6TZf2X3gHCMkGBhGQLBZN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgHdmfvGfiGx5kSn3GdRn72pWafGey6Jia", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbRktBVQovHF9Cc59M98VedTAFwgqg3jHJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU8mm9JDdtgHpwWEA6Snou1qvBgwVqQjio", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRPVT5h6VuNWyXWhtL1nMMTi7bGmw3yMDX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVbmnrrGqe9RjwX4EHU7w17AY2mUrJ9y37", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgBLAB1HtEU8nuuSEso6413ir8bv7y9NNY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV7LARZvy2Psz5kLfsD52uEeQwHuM4VYtn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbEJfhEUV4nqBeGsDUYiiJyHW2a7LHzX1q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLeJ8R9FKeyhVivaLuvTt3vcszKDxEBWXk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWazYjG28fWUvGCoxvVCwhz47hty7VgHdt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP15D6KGREk1eGZ8Pjb3LP8jw9oaywHRCH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVQDZsQSvsd3BFiAS45WjA6gquH7mKp3s", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qee9GheZLwpyYPEbGci65Cu9ywmfUpiUtA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVYGSLnspzWNtGCDBxtF26JMY1PRR9E8Mr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjQCea9aLKuXQNH6iqADfC7yngwVTdA5A2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXZJY8MgKXviC4xeuMoZ6zaYSm7dJqZYaA", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfv2jWMD3EC6sAGxKx8hRBSQVAt4YmtTvX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVzQVQCzL3AC8bAHGkbTCiy3xgeWGfsfR", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd31D4nhiCMnPFHoKdjeszqbNP914JZ8ro", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfgfpEia92nL94vxwRiSJp7ee5ZophKhJ9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQdJscTibkMvWkbZitYzrWLtnTxhgt7K7U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR5WMxJWgaBPUiDhyYvbgYfiNXMjvqg6UA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJKN4HYcMBw1BCxEPB79peKqyBE2o8pfz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQBRiZmS55ZEJNPj1VQBCQC7FVvafVdRBF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVyrsn9hn3evAQFm8ECjhRYeAqhDZgwiz7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaYtyv8etaQF7gQP2YKzeLqKyzrp8jrJpv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QStvmZNCzqNeyfzzeKrq5xQh83P1F6ERpt", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe6XX4Eghqm3psn3jSzwcsJ8N9yaaE6qXJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhy3Zg5D95QWrprgRyWL1Hta6JmMarP2TN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVG8Qdgn2yBsNRQDV7oW54r1whSEwvUk7M", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVsBXTUPszNJrdaT11rDSRewSdUjMQd5cs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPmFVFFkB72o8Th9D2wJxgZaz6unt9HwW7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRA1jykbch853CAsXXt9sEGBjsp835v3P2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSXfJsEHYTcwmcsJ9yoekCD4HULQpxeCBd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZhb63HLT4RFyczAXhvviLHvkQUi9q6mTX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUHuwaoxNusHj7ZUyTYjRFP9EETt2hixkV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRyvShLAW3tZEaydKZxLAA7R2GmErJFdn5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf4FAqg7uo9sDZVwSyRctiGgHNfyr77HGD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcxYc7fFZjMCtE3GAdr6YduTcPzWXux7kV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRwYWNYRYpP42uucjSMiSmrpteCyJuaatT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUw5tEoXvnGKK7bUKye9zGLyuumhJxE8ZY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYw5c1Ufeu3Xs6X4wDtEW3rY6mvJdyiV69", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZfcnG7M1KLuNVHFoAz75Q9axCPeZGvmnK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSWBNaUoWQtkDioGsRQVMevrNjMBNhFA9m", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfmyojs61pnVshq3AMb3SueZQJmZWGS6P5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbakZPnxpoFvUUA2ikFXEMfupnzL2g1Hpe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgYwhNYcrYXEVL5vu6xgtB5egYpspHu8Qr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLtzQ3BiNMDJr6UtibNGKNY5q1Lek22mbw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTxsroYE6NqWJEfCYRTN2kXFpvc1L6dywo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QauYtokfSq8oZC7MQJkkRUCHwV9RCJXfop", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiLH8NVybYU3gfwXqmApeeLoebMmdxsvy2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QboQVDYaeYEWoxxuq638FGFwFZwVbv3wZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTTMNosEkAGiFXLUVMczcmKA12Uj77Dm3G", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbo2JRDzV3DFruTjznyhzF2arhrKuNuz8C", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdbbfH115EuQzAcEPfV4adEVKEhq2rQE7P", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUQYqyWTkyXvnUxM3caZuEtDEDSbfJYpwd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdwmWF5FRsXNwn9aDh4SKWfhEuamnAXokH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNTqdLMtm1k6Q4iYpnvc5BcH9NBxanMkYC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi7J6Wzb6pSgaeyGXYmbNo6a3J7csQncYW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMaJeJ5MmK4UG3Zto9JgLhJ26JaPKSp6ha", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeCmhsUEgUe7ddLqCMHcX2be72BPFkQWd8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWTdjKcTE8DpSopLJsy1H6CsqupZSU3ZGB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeDrfr1wmu7okNMRZmbJ2EwZMFesEv9zMV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QizC1Qtg7UUDu4bDKibiTKEMhmGqP7C1Qb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZPQfSTVKjRYJ3r5P6orJsYaz6ZcG87ktd", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjt3WY2Je4xSBFA97ptGQBZfSpJb4jgxR4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTPUBwrW6aRQLHQpPrw4e2bDXR3gRWq3kF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZLRzY7RZy3h6pE5CAk57RUvR48HH2joWi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNAN8iRgrqKwPqrCojJQjBpEiEov5tirL2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiygUw8uXTLzDFJ2E7HBKHMHqiuFWCa6GB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaZpSiB9Nj8WdbL3MHvUzZBXeFSqEMiD7T", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgaJRHFpD7WQEpPKVkUSNzxae4hSUyeJvh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeN8j74amkd8GFyoRcxBaVrkHns2WxKjmS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLmQGf4imhLzZVAbX97MR9JFZik8JQ48B8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY81xXYuMaewGHHcrYNdxJzhi6dNqu2JRY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVm5E2rvGb5VVfWTAMA1VUAd34k7Gqr1q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhGHn89LXfEj7y4CjSLtadvn8ezL6cAScQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSCP8xGYg88n963nN9ejiDJeiNggwLRLpE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdzyigNad6fXJxykm46yWRH5tN8uDq4beF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQJQfXRwypwuD6oRHjcdRCKeUhdrCsDs8k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPCYAstwUstQDESpFbPBB1U28LEoRcPq1J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU4xvi1HzuxYEqQrxhKYnQ2D8hDmRimE14", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVgLeZ8poUf7CJJ2vUGUEwUiKJ1sgszTzE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcU5kKi3mX1VJzne44LZLpr3htebQtn7xy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdkxvWwbkLDnovvksuNDwyDHP3634CnSCU", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf9XqVqSSKDBG5F6AP7nUv1LpUMU6bmRnK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNfqTKfSU3E8RWoJtJgnKxwJDkzfXb18cW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSpEUGYR2rveDE4ryRPVjizHtRKnMRh5fN", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd1GMBYjor3X8AL1WzHFi7egejb45XtLzY", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbbzcfy5WjRejf5tHJLG14P3uvK23ywozr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWUSHC2Bpdr9PUNB8Hj9S7HF5iagWPWNEa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcftPPiBPU8Y1XuCzdAH6vduXHR5V7YFhv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR6rz7SHGgDC7QnNzWBCo6idbcxcXiLfBz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhgTuLEhWnxFCqCCADVP7Fh2oXXG9j3Dj9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTzaaunU9vbdibBHKqg5ZpVH1jcu4rCCm6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNCas5mqJLgeJHB5jQKXuCVBhqPfRHdYMF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhqWZL5643Y5C3RwvBSJpv3W6FA8GbFWJC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaVBebFHbsbhBcmrX9ocbnwEMJgzkHvMZQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdbj1tS7ZGrNdKXqEKfnt9EnwbCLLzyoCY", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjn22uhkomiP9H95G7XAbEVZFjKiGPfezV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3QnnhKZeUbjUtxGLJ8jQVb7XohfDy1Eq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLqNGEMTT6GGi3dChtp56ocae6XxkakTkf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZHLChVNfNNp5rsZQQKRRxkPGriUAyhK5s", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjrgs36ajjTFgrKUMvsDSW8xiNmDG1L2be", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbriz3o7KWJZCafQCt4ftJAAEh8Pvg8o8v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgPQSW3FVHbEKf4UBZh1WwVhLo4eTSXa44", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSchsxiHAmhvA59HBy4M9y2JobH7nwi5Xf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYEbJEiXbPjqKFpUWkbXBcFFUU1PbppZnw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNG95Qwbb5P4DGedLHv2kmYvMjG3GxbCEP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSFj8r42yFpaPQNwKWwSVQKVdozTniCk8G", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM4JVyX65WbLUYTyptAMea2MHsefGvUcR5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRey3hPPGc2ewP1Ztw4SFG1fyU1xiqLjCP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ8cte5J5R21uypoaoCvAALzBkYSePZHDF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcCRjkP1XeD1dvwU4umQ9cFWv8d3hJqjK6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUH61i6hsZehnXNJF5VefvLQcRCsNg3NPr", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbzgu32EF5nPsanMMXXsMNz1rQ4hcmnGZA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSCYGst9SJb4gz2H21Vq3DXquxzY73VWm2", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh9Mx3kaTcWfoYJDgeQuDJ487K8EEwTtDo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRQ7875Nwp9osH7GScPREnfLPX5RczZZ46", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjBn4bLZEeau7hx3Wae6ZWVtc7yCC79xEU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMG32pXpVopiMA6HLoawMi9x4WmwZBpotK", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa8uuqfKV9yZekwTNU2JWnj8rnZc2RRmco", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhRiACrq2Xgw491jZovDL5UqvmVDGXQfdG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZXGVbvKEp2G4c3dBHbTxtZKmu1x3k4Urx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX1oGefhKHCchNSUqycfzPjZp5NnwBoAGK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSw5SHLYZLe5NKT2ebMLAr6BbYJNQ6rWrd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQsUPNpkB2iERBFqVHJomgPvBEookzGgFP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSqw9dxfGhQJTstNgkxJmig9YTxVFaTo3i", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjFpwBMrZKsGDmi8tGgXp1m9P7xxcr758T", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa64xQ1Qqmc13H3W8KB6Z5rRPsoRztKZuM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRpCadQWjcJHeieoUnXTiSpqydRbYcA6qh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiG86w5cMT9iv7pekuRohJmFXUwkvjvMXm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgAUczBPFQz7UpVeukv8tEPGEtu4TkMAgv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRjAxvFYSsXSuwTgDQFAxFo1Vy8ntmij9w", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMc2ogsdyB9HUS6gka1XvJst6iWV6XDd1y", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjf73NRcLF18taDgZvrDXUNysViHiP81j8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLsnsdVELRJDkr355QCJXzzR29whaxbP3m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgocHNWrSPTrUGp6oiSg9gwsvAHD8pYVHi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbmbTWgUEH57JXHzdgUAy8H9HZD1Bzu2T5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNXG4iaaPiqd2RLA28FJwCk6csUU7Mh6rZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhsWKPi65qKKLVkn7DgopCn4h3f2W1FMvd", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe2TzQdF5MGsfFbytqEosFkmWA24i4YaQM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QULHofsgHS3B3whoFNXPHrNMJZDv4YUc96", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTwx66zP3H7PxNJjtX41BZZB6nEEpXGTV8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgjfghHZfQtNjjetUUNC5cHza4JebSAeyB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QitLt9FeT84swMehxuWnLqKrtfhaaW8nzm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdLxR3bofPLP2ZkwHKuhW1KzetRNyFeW35", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSkzC9kNFxZnKFiMaiGsdVoAGKjfBBZwcz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ4z5mEXUDQMqBXGQg5Cp2SvGMDTEZaXLD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJfNxUJvvnX9zRCxAFrMFz1YB1cYShNLv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQnyuF6J5AN7MvxZdxLL4r6qjYFmBpf6qd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJ5tQZCqh8KJmhbWKsx2uwUYXYbjyzUxz", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb6k1h4VvfoRsQeEnDSvsBe9PfJnsaRcax", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNjTDHtHXdVtfcRf8qTqSZgXLDiFBADBqX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMnrK51UipWbtiA3ogm24mhe7WRAJ17BmM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNdQuxwAjHcojdxYfkxnnkMNMDZ2Ym6sH2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN7HMxm2qCxHNTei5wmBfxFMr4cbb6xBAF", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfb3KNCYWsEzj7npPJxiNnQKw97Ly3BpEW", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg36i5Z5f12EYBR5PUZaf59Ub8KeEkYovh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNy6RzB1xWykv3Yb6uUDQ9VgLTRGoFPLKT", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf8cCCvv57r14pN4oJFVbLym4WWMERkA8H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV4VZbbdmuYtZKE1LXjQsojbb4nTJSw3wR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QReEeUBRRnsXUd4iMtgHAt6pfHZfVYD66H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QThtnUYKiXtx9ga7LtT9qftafdiVDZs2tQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbADUiFmkLpvyTZ6ug8kkE9j8aDcDm8W7o", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYYpvHzcUP4s9jSZzaNn1mqZDM4u26yAfT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQKCYjczFAKAgjYhRL1jjGcA6khh35Fu9F", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSa3xN2kdAwc6PBQw8UmFpK245cK34Aa8Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYuLzGnHLSvLtrDndk9GGPQnGU5MW2MtYE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN7zXUcHfBhn28qopFZ1R7pej4i8ndPibm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QedfstymDM3KpQPuNwARywnTniwFekBD56", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJMjw4LikMSf9LJ2Sfp6QrDZFVhtRauEj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRE5pZcGwZ7bSEWh7oXAS9Pb8wxcBLwQdJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaeYRDhR9UagFPGQuhjmahtmmEqj8Fscf3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUyubWVyz5PLPcvCTxe9YgVfRsPhU5PKwH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdxGiYrxV4Hr4P3hNT988cCo8CqjyNNtN2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY3jWQe5QbqQSMyLwf8JiMAbY2HRdAUQQ7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbTS7CNqoqhQW79MwDMZRDKoA4U3XuQedT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY8AQrKf1KE7MCKCWG1Lvh7q2mEWWqjnCh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZtRisdwd1o2raPA7KhCnF88msVJoyc3uZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdRio67LD8QCPmzXiwimvnNgSuXhdHy6hM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd6RrfCKZX3nx8wRCtJ6jJA9VJ4o7quuEe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiUbpcT8Uibzua79RRzqbLqA3MUkWG1Q3W", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXKBimE8Vbat755M9zmcKiiV4gkSLc6vfB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUC5EMMVav2Qt4TDe9Af39reYoFnamxUkn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjEaoBWyAP4Ff29dGUZtGsYdRvHKf8HKb2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS11w9zba8LPhicybuvxkTZCmTLMCt3HZ1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW2LGY6cQwmGycv5ELE23z38WqXFzsuTFx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcL5p8mwKk9g6xpwCXTiHpJWwRUKqUkgKc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPHLQxDwymECG1deHhhxNkEM8jTH3rfmvA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTR34yKDT59X1YJR4Y4HAnHJXXjwVHi1BM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR9EUCjXzD7hQETjnZrKTsQ9XQAWjZtN3d", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQQUNMU47F6LbMjC7wVhaPgw34ytetgbLT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUuugD6cTY5p7RFGnMrV78dfmEBrAAYx1N", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQktyzttrEtkN1iHQNAR3TfS1T5Xse9REv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPaWULDvmSr1cwhNiYzU59fnZkQmLQafWe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSvTyX62mGPTGLKA8TvmYcuct878LaLrsp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMKunzBtoEQ2Ab8emix8KCXQR9dcfN7gca", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZezsFhUeN3ayGjJ2QnPJpG8tHqfutnKxq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNBe19N5gNvgK1R4PvaYEinsAGTcbaQiNR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVkLgL3tx7aizRF64PAWnLn6VKTY4jGGXC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRsi8YiWAQKrBVNHyEAdcKy9P82NRK66pu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiktanAj2ACcLhcCLWSAf3oboZdrkvWkcu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXKXL8hen7hM8W1fFHUAfKbPxZqqiimTnD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP2NiMK9iATLo2bNRER3yuEk38VP4SC5jL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNkZHXusJXpreoxyo5ULyvPXZjkxA3UvEw", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qca4vfeoNVbtbHzJa5F3v8sWqZh51Fz5mR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiNYuBjPpwDo3b1iETYPZnZwtfQnpQGouR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV3sZ8AtdRr8YTEPnmxE9tMt7wxX4ruG8U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYkPYG6CEwpkxq5s3Sy6PHnn3SDXqvPSvb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhGkgG7EtqwuRYQ599DNn1jMfyzkNeZhKk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdHk2qSAMeiPqYRACaNyy1jpAVYzTLrdyv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTrtqtdN1Kxiu8YDumczZd4QvyRwAzk7FS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWxcJFLecFjrmejcCToGVRJpXueAZJgiEu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgU2udWFXJpDatHhmXWqFLxYCkYGSDUGLD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhT1YEHJpbuFrTqkRCyeAn5QdeJsNzjX6p", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjuZxHoptE264839GsNhjcWgrCAzcbDQQL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP9m28hRAd3Qz96CvBnwipR8J319b2sjzY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QawVhDFeqEd6aGfgRxHsLN7SnrhGqweSHC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVwpXi2jMj5aMVjZmXbfDiQwhKY3FJyiPk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZBRV9a8UBbdKaz63JqTnY3w2R62W1phiN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhQDb2NkMXo5TALywQwJm4jp5CxydPsrqf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVjGMUHTBkimNLGuDvctX8VPq1NkMAWJfc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYCd9YsdNpFeabaQVzoYUYAbUkXkERZNSZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbcacPWfXdQe4HpDr5ddbFBuuURLaaS1Dr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbSToBjt9g75sCM2SUEgJNb6uskeTyzPbb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeToG5yenFa8TfHJUE17898D2RVZ76tYiT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNBHmU7jgD3HyfD7qFzKAMcgdw7Pr3FTBm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeFMFEzN6nEtC42MdffLBB2RbyUKMq9BPf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQmVTLRTBBQ8c2syo379Koydj1RNCAhUw5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWWKVymgzeYECUwomWkaxioMAmpmotUh62", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM8xDvrXzLRo41SjkNeMTYoP3tKsaLcQze", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRkhzEqETQczL8xHV8P98rwu84755SDbWP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLiSBYE3QzNsWijsF8BTNzziLfyVB6nV4q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcS9jgiG6AptzioTUfUJr5oXJYQES275xU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfkPYKpTfotYzN5BhKXENDgt1f3vo8LTSp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ9NUxhdgtvvxTSZqYY6k9qxHziqSQ5jcK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ94CoMHUyNGxtef6QHMUNRd8D3NNUgM5V", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qce4PQd34icYvN463Smi9ahVGxznoax9Wi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR3MXpsu8ig4PJHJEfKsnDuxrSDLrDzLd8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXHQEBm9CtVTY9RNdCDxju7yr61DW3K8JL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhFG2as3oZVYSubieisTPHco58pgw2nr5E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY3dGvuVkQADmQYndkKv7sLBG6JeduhVbz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMSx3vQagdw6QD4D9SiiDRMhDrNFGjhUpd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVBktFMtw31ye88qjR2LTkfFGgoRkXyf7w", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPr17q2iYVQ5kMEtmUmEBN3WpMF6DjzR2x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZituXHq3AzDdi9PDhtA5jySAC4VBUk6UJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYaVmr36tGTH4g5iTCeB5tZu3u81yp6M1G", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa51wH3pbN1bDpDWwpDDRrccwVmZo4CdXc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbQB5hSA7P2ssdYXzxWcbDePL6SDDBUpgQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeKHm4Rg7ANF6RBfphgS9gkYhLEboJUP8v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSpjpN64bYczYNsKsgmNmNDAFiKUg9orJA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdmwboMpKVpnvdYZgiaEXpuEnygDRxyywc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLnRWFKRGRtQAmX2aGM1F5vXvEb7naUUBG", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qaqb6saKN4YuHVKJ2HEDgKWAzGhJQ43sic", "amount": 10 }, + { "type": "GENESIS", "recipient": "QadBYsejVVWyFneDMpCffjbBpxgF9AEatD", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh6K3QdnKBkb4u5Z3wD73C4sTjvcBTgiFC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa7td7KrALcVXpMcv5GzvrtGMAPKHUUECb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaqNZLJBCJTas5Frp43jzxEYvEoWdgYeXJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUJoDzJ8WDG62MSpMfzxUDH1pwJ6aRWZL4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeVt9GFpDSdg73XQbVdCU4LHgMp9eysYa1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgzD9PSp1P5WVkyifGxCcoV7TXzLWL4GgN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWDtjA76XhCfXw6gvYfo3MFcbKCX2ZEyLJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM9wXFKoAYkmDwCkz1Vdsn9vyMeRRKRCzy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWyp8eCTuCnT32vYQEj5rywCXWxYm36dov", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSGJrJSGub71GrjGSXJSZMFUtHEn7C5TUW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNhPdmMHBUPJL6yvghnTFnRajMBmdqddZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZaZctoQRrR2g1bhAfzb5Z5ZMANGVkBG5u", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUhct2oBCmaU6kYguDNcbU6HQss9QELpLJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSwQUg1aWMQ7kQcR6WMWa5SHxarGdG3DgW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdQmZTpA8a2YnrZAykVhNpGhk4kVmjnwRL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVTD43EpJ4iFXKJwnofocwcopw1iYo1TP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeXsnu3X1FsmLMRPYPJcsfJBVrTtwW4qrR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVTgyvvRGrd56BrFLvQoaF3DAYBXaobwef", "amount": 10 }, + { "type": "GENESIS", "recipient": "QczCL1E9G6fpifK2pFgDQiV2N5M7X54vAV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYQs34RxFv7rtYAx9mErUabnJDvCfBe8gY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QanakqWSmEB6oQkrWVDRArG4wTHPs3zw4T", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNiGHSk13xXy54KuCqQ5PQZBQa13DhPb84", "amount": 10 }, + { "type": "GENESIS", "recipient": "QemRZy1gnzY1j5czckXAoBqW2Ae32onBPn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNzqkJgXKy4Gi22hGgyMMThFeG6KSYUwEb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ3cnhqAJVyCwYBZmgjnvDz76bKyJCXa1d", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa4ZKZEgKNRDNADY97aB95VYQMa2CUYV7y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWceBxyxTA9AUocwwennBg3eLb97W5K7E4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaX4UkVvH27H3RkMtKebMoBvJCcEbDiUjq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjJnJQfaPYJdcRsKHABNKL9VYKbQBJ4Jkk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhqaWQkLXTzotnRoUnT8T6sQneiwnR4nkM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQwemW9rxyyZRv428hr374p92KLhk3qjKP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaeT4E1ihqYKa5jTxByN9n33v5aP6f8s9C", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUaiuJWKnNr9ZZBzGWd2jSKoS2W6nTFGuM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVQroJR4kUYmvexsCZGxUD3noQ3JSStS4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPcDkEHxDKmJBnXoVE5rPmkgm5jX2wBX3Z", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfSgCJLRfEWixHQ2nF5Nqz2T7rnNsy7uWS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU7EUWDZz7qJVPih3wL9RKTHRfPFy4ASHC", "amount": 10 } + ] + } +} \ No newline at end of file diff --git a/tools/approve-auto-update.sh b/tools/approve-auto-update.sh index cbfa280d..c34b6de7 100755 --- a/tools/approve-auto-update.sh +++ b/tools/approve-auto-update.sh @@ -50,7 +50,7 @@ tx_json=$( cat < 0; seconds--)); do + if [ "${seconds}" = "1" ]; then + plural="" + fi + printf "\rBroadcasting in %d second%s...(CTRL-C) to abort " $seconds $plural + sleep 1 +done + +printf "\rBroadcasting signed GROUP_APPROVAL transaction... \n" +result=$( curl --silent --url "http://localhost:${port}/transactions/process" --data @- <<< "${signed_tx}" ) +printf "API response:\n%s\n" "${result}" diff --git a/tools/publish-auto-update-v5.pl b/tools/publish-auto-update-v5.pl deleted file mode 100755 index aad49d4e..00000000 --- a/tools/publish-auto-update-v5.pl +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env perl - -use strict; -use warnings; -use POSIX; -use Getopt::Std; - -sub usage() { - die("usage: $0 [-p api-port] dev-private-key [short-commit-hash]\n"); -} - -my %opt; -getopts('p:', \%opt); - -usage() if @ARGV < 1 || @ARGV > 2; - -my $port = $opt{p} || 12391; -my $privkey = shift @ARGV; -my $commit_hash = shift @ARGV; - -my $git_dir = `git rev-parse --show-toplevel`; -die("Cannot determine git top level dir\n") unless $git_dir; - -chomp $git_dir; -chdir($git_dir) || die("Can't change directory to $git_dir: $!\n"); - -open(POM, '<', 'pom.xml') || die ("Can't open 'pom.xml': $!\n"); -my $project; -while () { - if (m/(\w+)<.artifactId>/o) { - $project = $1; - last; - } -} -close(POM); - -# Do we need to determine commit hash? -unless ($commit_hash) { - # determine git branch - my $branch_name = ` git symbolic-ref -q HEAD `; - chomp $branch_name; - $branch_name =~ s|^refs/heads/||; # ${branch_name##refs/heads/} - - # short-form commit hash on base branch (non-auto-update) - $commit_hash ||= `git show --no-patch --format=%h`; - die("Can't find commit hash\n") if ! defined $commit_hash; - chomp $commit_hash; - printf "Commit hash on '%s' branch: %s\n", $branch_name, $commit_hash; -} else { - printf "Using given commit hash: %s\n", $commit_hash; -} - -# build timestamp / commit timestamp on base branch -my $timestamp = `git show --no-patch --format=%ct ${commit_hash}`; -die("Can't determine commit timestamp\n") if ! defined $timestamp; -$timestamp *= 1000; # Convert to milliseconds - -# locate sha256 utility -my $SHA256 = `which sha256sum || which sha256`; -chomp $SHA256; -die("Can't find sha256sum or sha256\n") unless length($SHA256) > 0; - -# SHA256 of actual update file -my $sha256 = `git show auto-update-${commit_hash}:${project}.update | ${SHA256} | head -c 64`; -die("Can't calculate SHA256 of ${project}.update\n") unless $sha256 =~ m/(\S{64})/; -chomp $sha256; - -# long-form commit hash of HEAD on auto-update branch -my $update_hash = `git rev-parse refs/heads/auto-update-${commit_hash}`; -die("Can't find commit hash for HEAD on auto-update-${commit_hash} branch\n") if ! defined $update_hash; -chomp $update_hash; - -printf "Build timestamp (ms): %d / 0x%016x\n", $timestamp, $timestamp; -printf "Auto-update commit hash: %s\n", $update_hash; -printf "SHA256 of ${project}.update: %s\n", $sha256; - -my $tx_type = 10; -my $tx_timestamp = time() * 1000; -my $tx_group_id = 1; -my $service = 1; -printf "\nARBITRARY(%d) transaction with timestamp %d, txGroupID %d and service %d\n", $tx_type, $tx_timestamp, $tx_group_id, $service; - -my $data_hex = sprintf "%016x%s%s", $timestamp, $update_hash, $sha256; -printf "\nARBITRARY transaction data payload: %s\n", $data_hex; - -my $n_payments = 0; -my $data_type = 1; # RAW_DATA -my $data_length = length($data_hex) / 2; # two hex chars per byte -my $fee = 0; -my $nonce = 0; -my $name_length = 0; -my $identifier_length = 0; -my $method = 0; # PUT -my $secret_length = 0; -my $compression = 0; # None -my $metadata_hash_length = 0; - -die("Something's wrong: data length is not 60 bytes!\n") if $data_length != 60; - -my $pubkey = `curl --silent --url http://localhost:${port}/utils/publickey --data ${privkey}`; -die("Can't convert private key to public key:\n$pubkey\n") unless $pubkey =~ m/^\w{44}$/; -printf "\nPublic key: %s\n", $pubkey; - -my $pubkey_hex = `curl --silent --url http://localhost:${port}/utils/frombase58 --data ${pubkey}`; -die("Can't convert base58 public key to hex:\n$pubkey_hex\n") unless $pubkey_hex =~ m/^[A-Za-z0-9]{64}$/; -printf "Public key hex: %s\n", $pubkey_hex; - -my $address = `curl --silent --url http://localhost:${port}/addresses/convert/${pubkey}`; -die("Can't convert base58 public key to address:\n$address\n") unless $address =~ m/^\w{33,34}$/; -printf "Address: %s\n", $address; - -my $reference = `curl --silent --url http://localhost:${port}/addresses/lastreference/${address}`; -die("Can't fetch last reference for $address:\n$reference\n") unless $reference =~ m/^\w{87,88}$/; -printf "Last reference: %s\n", $reference; - -my $reference_hex = `curl --silent --url http://localhost:${port}/utils/frombase58 --data ${reference}`; -die("Can't convert base58 reference to hex:\n$reference_hex\n") unless $reference_hex =~ m/^[A-Za-z0-9]{128}$/; -printf "Last reference hex: %s\n", $reference_hex; - -my $raw_tx_hex = sprintf("%08x%016x%08x%s%s%08x%08x%08x%08x%08x%08x%08x%08x%02x%08x%s%08x%08x%016x", $tx_type, $tx_timestamp, $tx_group_id, $reference_hex, $pubkey_hex, $nonce, $name_length, $identifier_length, $method, $secret_length, $compression, $n_payments, $service, $data_type, $data_length, $data_hex, $data_length, $metadata_hash_length, $fee); -printf "\nRaw transaction hex:\n%s\n", $raw_tx_hex; - -my $raw_tx = `curl --silent --url http://localhost:${port}/utils/tobase58/${raw_tx_hex}`; -die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{300,320}$/; # Roughly 305 to 320 base58 chars -printf "\nRaw transaction (base58):\n%s\n", $raw_tx; - -my $computed_tx = `curl --silent -X POST --url http://localhost:${port}/arbitrary/compute -d "${raw_tx}"`; -die("Can't compute nonce for transaction:\n$computed_tx\n") unless $computed_tx =~ m/^\w{300,320}$/; # Roughly 300 to 320 base58 chars -printf "\nRaw computed transaction (base58):\n%s\n", $computed_tx; - -my $sign_data = qq|' { "privateKey": "${privkey}", "transactionBytes": "${computed_tx}" } '|; -my $signed_tx = `curl --silent -H "accept: text/plain" -H "Content-Type: application/json" --url http://localhost:${port}/transactions/sign --data ${sign_data}`; -die("Can't sign raw transaction:\n$signed_tx\n") unless $signed_tx =~ m/^\w{390,410}$/; # +90ish longer than $raw_tx -printf "\nSigned transaction:\n%s\n", $signed_tx; - -# Check we can actually fetch update -my $origin = `git remote get-url origin`; -die("Unable to get github url for 'origin'?\n") unless $origin && $origin =~ m/:(.*)\.git$/; -my $repo = $1; -my $update_url = "https://github.com/${repo}/raw/${update_hash}/${project}.update"; - -my $fetch_result = `curl --silent -o /dev/null --location --range 0-1 --head --write-out '%{http_code}' --url ${update_url}`; -die("\nUnable to fetch update from ${update_url}\n") if $fetch_result ne '200'; -printf "\nUpdate fetchable from ${update_url}\n"; - -# Flush STDOUT after every output -$| = 1; -print "\n"; -for (my $delay = 5; $delay > 0; --$delay) { - printf "\rSubmitting transaction in %d second%s... CTRL-C to abort ", $delay, ($delay != 1 ? 's' : ''); - sleep 1; -} - -printf "\rSubmitting transaction NOW... \n"; -my $result = `curl --silent --url http://localhost:${port}/transactions/process --data ${signed_tx}`; -chomp $result; -die("Transaction wasn't accepted:\n$result\n") unless $result eq 'true'; - -my $decoded_tx = `curl --silent -H "Content-Type: application/json" --url http://localhost:${port}/transactions/decode --data ${signed_tx}`; -printf "\nTransaction accepted:\n$decoded_tx\n"; diff --git a/tools/publish-auto-update.pl b/tools/publish-auto-update.pl index ad43b2f4..9e6b885b 100755 --- a/tools/publish-auto-update.pl +++ b/tools/publish-auto-update.pl @@ -4,6 +4,7 @@ use strict; use warnings; use POSIX; use Getopt::Std; +use File::Slurp; sub usage() { die("usage: $0 [-p api-port] dev-private-key [short-commit-hash]\n"); @@ -34,6 +35,8 @@ while () { } close(POM); +my $apikey = read_file('apikey.txt'); + # Do we need to determine commit hash? unless ($commit_hash) { # determine git branch @@ -84,9 +87,16 @@ my $data_hex = sprintf "%016x%s%s", $timestamp, $update_hash, $sha256; printf "\nARBITRARY transaction data payload: %s\n", $data_hex; my $n_payments = 0; -my $is_raw = 1; # RAW_DATA +my $data_type = 1; # RAW_DATA my $data_length = length($data_hex) / 2; # two hex chars per byte -my $fee = 0.001 * 1e8; +my $fee = 0.01 * 1e8; +my $nonce = 0; +my $name_length = 0; +my $identifier_length = 0; +my $method = 0; # PUT +my $secret_length = 0; +my $compression = 0; # None +my $metadata_hash_length = 0; die("Something's wrong: data length is not 60 bytes!\n") if $data_length != 60; @@ -110,16 +120,16 @@ my $reference_hex = `curl --silent --url http://localhost:${port}/utils/frombase die("Can't convert base58 reference to hex:\n$reference_hex\n") unless $reference_hex =~ m/^[A-Za-z0-9]{128}$/; printf "Last reference hex: %s\n", $reference_hex; -my $raw_tx_hex = sprintf("%08x%016x%08x%s%s%08x%08x%02x%08x%s%016x", $tx_type, $tx_timestamp, $tx_group_id, $reference_hex, $pubkey_hex, $n_payments, $service, $is_raw, $data_length, $data_hex, $fee); +my $raw_tx_hex = sprintf("%08x%016x%08x%s%s%08x%08x%08x%08x%08x%08x%08x%08x%02x%08x%s%08x%08x%016x", $tx_type, $tx_timestamp, $tx_group_id, $reference_hex, $pubkey_hex, $nonce, $name_length, $identifier_length, $method, $secret_length, $compression, $n_payments, $service, $data_type, $data_length, $data_hex, $data_length, $metadata_hash_length, $fee); printf "\nRaw transaction hex:\n%s\n", $raw_tx_hex; my $raw_tx = `curl --silent --url http://localhost:${port}/utils/tobase58/${raw_tx_hex}`; -die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{255,265}$/; # Roughly 255 to 265 base58 chars +die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{300,320}$/; # Roughly 305 to 320 base58 chars printf "\nRaw transaction (base58):\n%s\n", $raw_tx; my $sign_data = qq|' { "privateKey": "${privkey}", "transactionBytes": "${raw_tx}" } '|; my $signed_tx = `curl --silent -H "accept: text/plain" -H "Content-Type: application/json" --url http://localhost:${port}/transactions/sign --data ${sign_data}`; -die("Can't sign raw transaction:\n$signed_tx\n") unless $signed_tx =~ m/^\w{345,355}$/; # +90ish longer than $raw_tx +die("Can't sign raw transaction:\n$signed_tx\n") unless $signed_tx =~ m/^\w{390,410}$/; # +90ish longer than $raw_tx printf "\nSigned transaction:\n%s\n", $signed_tx; # Check we can actually fetch update diff --git a/tools/qdn b/tools/qdn index 869bf5c4..ea52e3c9 100755 --- a/tools/qdn +++ b/tools/qdn @@ -8,11 +8,11 @@ if [ -z "$*" ]; then echo "Usage:" echo echo "Host/update data:" - echo "qdata POST [service] [name] PATH [dirpath] " - echo "qdata POST [service] [name] STRING [data-string] " + echo "qdn POST [service] [name] PATH [dirpath] " + echo "qdn POST [service] [name] STRING [data-string] " echo echo "Fetch data:" - echo "qdata GET [service] [name] " + echo "qdn GET [service] [name] " echo echo "Notes:" echo "- When requesting a resource, please use 'default' to indicate a file with no identifier." diff --git a/tools/tx.pl b/tools/tx.pl index db6958e2..7cdf444b 100755 --- a/tools/tx.pl +++ b/tools/tx.pl @@ -1,16 +1,23 @@ #!/usr/bin/env perl +# v4.0.2 + use JSON; use warnings; use strict; use Getopt::Std; use File::Basename; +use Digest::SHA qw( sha256 sha256_hex ); +use Crypt::RIPEMD160; our %opt; getopts('dpst', \%opt); my $proc = basename($0); +my $dirname = dirname($0); +my $OPENSSL_SIGN = "${dirname}/openssl-sign.sh"; +my $OPENSSL_PRIV_TO_PUB = index(`$ENV{SHELL} -i -c 'openssl version;exit' 2>/dev/null`, 'OpenSSL 3.') != -1; if (@ARGV < 1) { print STDERR "usage: $proc [-d] [-p] [-s] [-t] []\n"; @@ -24,8 +31,16 @@ if (@ARGV < 1) { exit 2; } -our $BASE_URL = $ENV{BASE_URL} || $opt{t} ? 'http://localhost:62391' : 'http://localhost:12391'; -our $DEFAULT_FEE = 0.001; +our @b58 = qw{ + 1 2 3 4 5 6 7 8 9 + A B C D E F G H J K L M N P Q R S T U V W X Y Z + a b c d e f g h i j k m n o p q r s t u v w x y z +}; +our %b58 = map { $b58[$_] => $_ } 0 .. 57; +our %reverseb58 = reverse %b58; + +our $BASE_URL = $ENV{BASE_URL} || ($opt{t} ? 'http://localhost:62391' : 'http://localhost:12391'); +our $DEFAULT_FEE = 0.01; our %TRANSACTION_TYPES = ( payment => { @@ -42,6 +57,7 @@ our %TRANSACTION_TYPES = ( create_group => { url => 'groups/create', required => [qw(groupName description isOpen approvalThreshold)], + defaults => { minimumBlockDelay => 10, maximumBlockDelay => 30 }, key_name => 'creatorPublicKey', }, update_group => { @@ -71,7 +87,12 @@ our %TRANSACTION_TYPES = ( }, add_group_admin => { url => 'groups/addadmin', - required => [qw(groupId member)], + required => [qw(groupId txGroupId member)], + key_name => 'ownerPublicKey', + }, + remove_group_admin => { + url => 'groups/removeadmin', + required => [qw(groupId txGroupId admin)], key_name => 'ownerPublicKey', }, group_approval => { @@ -108,7 +129,7 @@ our %TRANSACTION_TYPES = ( }, update_name => { url => 'names/update', - required => [qw(newName newData)], + required => [qw(name newName newData)], key_name => 'ownerPublicKey', }, # reward-shares @@ -139,13 +160,21 @@ our %TRANSACTION_TYPES = ( key_name => 'senderPublicKey', pow_url => 'addresses/publicize/compute', }, - # Cross-chain trading - build_trade => { - url => 'crosschain/build', - required => [qw(initialQortAmount finalQortAmount fundingQortAmount secretHash bitcoinAmount)], - optional => [qw(tradeTimeout)], + # AT + deploy_at => { + url => 'at', + required => [qw(name description aTType tags creationBytes amount)], + optional => [qw(assetId)], key_name => 'creatorPublicKey', - defaults => { tradeTimeout => 10800 }, + defaults => { assetId => 0 }, + }, + # Cross-chain trading + create_trade => { + url => 'crosschain/tradebot/create', + required => [qw(qortAmount fundingQortAmount foreignAmount receivingAddress)], + optional => [qw(tradeTimeout foreignBlockchain)], + key_name => 'creatorPublicKey', + defaults => { tradeTimeout => 1440, foreignBlockchain => 'LITECOIN' }, }, trade_recipient => { url => 'crosschain/tradeoffer/recipient', @@ -191,7 +220,7 @@ if (@ARGV < @required + 1) { my $priv_key = shift @ARGV; -my $account = account($priv_key); +my $account; my $raw; if ($tx_type ne 'sign') { @@ -210,6 +239,8 @@ if ($tx_type ne 'sign') { %extras = (%extras, @ARGV); + $account = account($priv_key, %extras); + $raw = build_raw($tx_type, $account, %extras); printf "Raw: %s\n", $raw if $opt{d} || (!$opt{s} && !$opt{p}); @@ -224,7 +255,7 @@ if ($tx_type ne 'sign') { } if ($opt{s}) { - my $signed = sign($account->{private}, $raw); + my $signed = sign($priv_key, $raw); printf "Signed: %s\n", $signed if $opt{d} || $tx_type eq 'sign'; if ($opt{p}) { @@ -241,15 +272,25 @@ if ($opt{s}) { } sub account { - my ($creator) = @_; + my ($privkey, %extras) = @_; - my $account = { private => $creator }; - $account->{public} = api('utils/publickey', $creator); - $account->{address} = api('addresses/convert/{publickey}', '', '{publickey}', $account->{public}); + my $account = { private => $privkey }; + $account->{public} = $extras{publickey} || priv_to_pub($privkey); + $account->{address} = $extras{address} || pubkey_to_address($account->{public}); # api('addresses/convert/{publickey}', '', '{publickey}', $account->{public}); return $account; } +sub priv_to_pub { + my ($privkey) = @_; + + if ($OPENSSL_PRIV_TO_PUB) { + return openssl_priv_to_pub($privkey); + } else { + return api('utils/publickey', $privkey); + } +} + sub build_raw { my ($type, $account, %extras) = @_; @@ -301,6 +342,21 @@ sub build_raw { sub sign { my ($private, $raw) = @_; + if (-x "$OPENSSL_SIGN") { + my $private_hex = decode_base58($private); + chomp $private_hex; + + my $raw_hex = decode_base58($raw); + chomp $raw_hex; + + my $sig = `${OPENSSL_SIGN} ${private_hex} ${raw_hex}`; + chomp $sig; + + my $sig58 = encode_base58(${raw_hex} . ${sig}); + chomp $sig58; + return $sig58; + } + my $json = <<" __JSON__"; { "privateKey": "$private", @@ -339,7 +395,14 @@ sub api { my $curl = "curl --silent --output - --url '$BASE_URL/$url'"; if (defined $postdata && $postdata ne '') { $postdata =~ tr|\n| |s; - $curl .= " --header 'Content-Type: application/json' --data-binary '$postdata'"; + + if ($postdata =~ /^\s*\{/so) { + $curl .= " --header 'Content-Type: application/json'"; + } else { + $curl .= " --header 'Content-Type: text/plain'"; + } + + $curl .= " --data-binary '$postdata'"; $method = 'POST'; } my $response = `$curl 2>/dev/null`; @@ -351,3 +414,87 @@ sub api { return $response; } + +sub encode_base58 { + use integer; + my @in = map { hex($_) } ($_[0] =~ /(..)/g); + my $bzeros = length($1) if join('', @in) =~ /^(0*)/; + my @out; + my $size = 2 * scalar @in; + for my $c (@in) { + for (my $j = $size; $j--; ) { + $c += 256 * ($out[$j] // 0); + $out[$j] = $c % 58; + $c /= 58; + } + } + my $out = join('', map { $reverseb58{$_} } @out); + return $1 if $out =~ /(1{$bzeros}[^1].*)/; + return $1 if $out =~ /(1{$bzeros})/; + die "Invalid base58!\n"; +} + + +sub decode_base58 { + use integer; + my @out; + my $azeros = length($1) if $_[0] =~ /^(1*)/; + for my $c ( map { $b58{$_} } $_[0] =~ /./g ) { + die("Invalid character!\n") unless defined $c; + for (my $j = length($_[0]); $j--; ) { + $c += 58 * ($out[$j] // 0); + $out[$j] = $c % 256; + $c /= 256; + } + } + shift @out while @out && $out[0] == 0; + unshift(@out, (0) x $azeros); + return sprintf('%02x' x @out, @out); +} + +sub openssl_priv_to_pub { + my ($privkey) = @_; + + my $privkey_hex = decode_base58($privkey); + + my $key_type = "04"; # hex + my $length = "20"; # hex + + my $asn1 = <<"__ASN1__"; +asn1=SEQUENCE:private_key + +[private_key] +version=INTEGER:0 +included=SEQUENCE:key_info +raw=FORMAT:HEX,OCTETSTRING:${key_type}${length}${privkey_hex} + +[key_info] +type=OBJECT:ED25519 + +__ASN1__ + + my $output = `echo "${asn1}" | openssl asn1parse -i -genconf - -out - | openssl pkey -in - -inform der -noout -text_pub`; + + # remove colons + my $pubkey = ''; + $pubkey .= $1 while $output =~ m/([0-9a-f]{2})(?::|$)/g; + + return encode_base58($pubkey); +} + +sub pubkey_to_address { + my ($pubkey) = @_; + + my $pubkey_hex = decode_base58($pubkey); + my $pubkey_raw = pack('H*', $pubkey_hex); + + my $pkh_hex = Crypt::RIPEMD160->hexhash(sha256($pubkey_raw)); + $pkh_hex =~ tr/ //ds; + + my $version = '3a'; # hex + + my $raw = pack('H*', $version . $pkh_hex); + my $chksum = substr(sha256_hex(sha256($raw)), 0, 8); + + return encode_base58($version . $pkh_hex . $chksum); +}