From 0693e26cda72374876e5c134b3e3c486be0f5afd Mon Sep 17 00:00:00 2001 From: Nuc1eoN <2538022+Nuc1eoN@users.noreply.github.com> Date: Sun, 9 Oct 2022 15:34:20 +0200 Subject: [PATCH 01/57] Update/add German translation --- .../resources/i18n/ApiError_de.properties | 48 ++--- src/main/resources/i18n/SysTray_de.properties | 20 +- .../i18n/TransactionValidity_de.properties | 195 ++++++++++++++++++ 3 files changed, 229 insertions(+), 34 deletions(-) create mode 100644 src/main/resources/i18n/TransactionValidity_de.properties diff --git a/src/main/resources/i18n/ApiError_de.properties b/src/main/resources/i18n/ApiError_de.properties index 8f5bffeb..f00c2b45 100644 --- a/src/main/resources/i18n/ApiError_de.properties +++ b/src/main/resources/i18n/ApiError_de.properties @@ -4,52 +4,52 @@ # "localeLang": "de", ### Common ### -JSON = JSON Nachricht konnte nicht geparst werden +JSON = JSON-Nachricht konnte nicht geparst werden -INSUFFICIENT_BALANCE = Kein Ausgleich +INSUFFICIENT_BALANCE = Guthaben reicht nicht aus UNAUTHORIZED = API-Aufruf nicht autorisiert REPOSITORY_ISSUE = Repository-Fehler -NON_PRODUCTION = Dieser APi-Aufruf ist nicht gestattet für Produtkion +NON_PRODUCTION = dieser API-Aufruf ist für Produktionssysteme nicht gestattet -BLOCKCHAIN_NEEDS_SYNC = Blockchain muss sich erst verbinden +BLOCKCHAIN_NEEDS_SYNC = Blockchain muss sich erst synchronisieren -NO_TIME_SYNC = noch keine Uhrensynchronisation +NO_TIME_SYNC = Uhrzeit noch nicht synchronisiert ### Validation ### -INVALID_SIGNATURE = ungültige Signatur +INVALID_SIGNATURE = Signatur ungültig -INVALID_ADDRESS = ungültige Adresse +INVALID_ADDRESS = Adresse ungültig -INVALID_PUBLIC_KEY = ungültiger public key +INVALID_PUBLIC_KEY = öffentlicher Schlüssel ungültig -INVALID_DATA = ungültige Daten +INVALID_DATA = Daten ungültig -INVALID_NETWORK_ADDRESS = ungültige Netzwerk Adresse +INVALID_NETWORK_ADDRESS = Netzwerk Adresse ungültig -ADDRESS_UNKNOWN = Account Adresse unbekannt +ADDRESS_UNKNOWN = Kontoadresse unbekannt -INVALID_CRITERIA = ungültige Suchkriterien +INVALID_CRITERIA = Suchkriterien ungültig -INVALID_REFERENCE = ungültige Referenz +INVALID_REFERENCE = Referenz ungültig TRANSFORMATION_ERROR = konnte JSON nicht in eine Transaktion umwandeln -INVALID_PRIVATE_KEY = ungültiger private key +INVALID_PRIVATE_KEY = öffentlicher Schlüssel ungültig -INVALID_HEIGHT = ungültige block height +INVALID_HEIGHT = Blockhöhe ungültig -CANNOT_MINT = Account kann nicht minten +CANNOT_MINT = Konto kann nicht prägen ### Blocks ### -BLOCK_UNKNOWN = block unbekannt +BLOCK_UNKNOWN = Block unbekannt ### Transactions ### TRANSACTION_UNKNOWN = Transaktion unbekannt -PUBLIC_KEY_NOT_FOUND = public key wurde nicht gefunden +PUBLIC_KEY_NOT_FOUND = öffentlicher Schlüssel wurde nicht gefunden # this one is special in that caller expected to pass two additional strings, hence the two %s TRANSACTION_INVALID = Transaktion ungültig: %s (%s) @@ -58,19 +58,19 @@ TRANSACTION_INVALID = Transaktion ungültig: %s (%s) NAME_UNKNOWN = Name unbekannt ### Asset ### -INVALID_ASSET_ID = ungültige asset ID +INVALID_ASSET_ID = Vermögenswert-Kennung ungültig -INVALID_ORDER_ID = ungültige asset order ID +INVALID_ORDER_ID = Vermögenswert-Auftragssnummer ungültig -ORDER_UNKNOWN = unbekannte asset order ID +ORDER_UNKNOWN = Vermögenswert-Auftragssnummer unbekannt ### Groups ### GROUP_UNKNOWN = Gruppe unbekannt ### Foreign Blockchain ### -FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = fremde Blockchain oder ElectrumX Netzwerk Problem +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = fremde Blockchain oder ElectrumX Netzwerkproblem -FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = unzureichend Bilanz auf fremde blockchain +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = unzureichendes Guthaben auf fremder blockchain FOREIGN_BLOCKCHAIN_TOO_SOON = zu früh um fremde Blockchain-Transaktionen zu übertragen (Sperrzeit/mittlere Blockzeit) @@ -80,4 +80,4 @@ ORDER_SIZE_TOO_SMALL = Bestellmenge zu niedrig ### Data ### FILE_NOT_FOUND = Datei nicht gefunden -NO_REPLY = Peer hat nicht mit Daten verbinden +NO_REPLY = Peer hat nicht in vorgegebener Zeit geantwortet diff --git a/src/main/resources/i18n/SysTray_de.properties b/src/main/resources/i18n/SysTray_de.properties index b949ca8c..f6dbc740 100644 --- a/src/main/resources/i18n/SysTray_de.properties +++ b/src/main/resources/i18n/SysTray_de.properties @@ -1,7 +1,7 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -APPLYING_UPDATE_AND_RESTARTING = Automatisches Update anwenden und neu starten … +APPLYING_UPDATE_AND_RESTARTING = Automatisches Update anwenden und neu starten... AUTO_UPDATE = Automatisches Update @@ -17,7 +17,7 @@ CONNECTION = Verbindung CONNECTIONS = Verbindungen -CREATING_BACKUP_OF_DB_FILES = Erstellen Backup von Datenbank Dateien … +CREATING_BACKUP_OF_DB_FILES = Erstelle Backup von Datenbank Dateien... DB_BACKUP = Datenbank Backup @@ -29,18 +29,18 @@ EXIT = Verlassen LITE_NODE = Lite node -MINTING_DISABLED = NOT minting +MINTING_DISABLED = Münzprägung inaktiv -MINTING_ENABLED = \u2714 Minting +MINTING_ENABLED = \u2714 Münzprägung aktiv -OPEN_UI = Öffne UI +OPEN_UI = Öffne Benutzeroberfläche -PERFORMING_DB_CHECKPOINT = Speichern nicht übergebener Datenbank Änderungen … +PERFORMING_DB_CHECKPOINT = Speichere unerfasste Datenbankänderungen... -PERFORMING_DB_MAINTENANCE = Planmäßige Wartung durchführen... +PERFORMING_DB_MAINTENANCE = Planmäßige Wartung wird durchgeführt... -SYNCHRONIZE_CLOCK = Synchronisiere Uhr +SYNCHRONIZE_CLOCK = Synchronisiere Uhrzeit -SYNCHRONIZING_BLOCKCHAIN = Synchronisierung +SYNCHRONIZING_BLOCKCHAIN = Synchronisiere -SYNCHRONIZING_CLOCK = Synchronisierung Uhr +SYNCHRONIZING_CLOCK = Uhrzeit wird synchronisiert diff --git a/src/main/resources/i18n/TransactionValidity_de.properties b/src/main/resources/i18n/TransactionValidity_de.properties new file mode 100644 index 00000000..eab7fb9e --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_de.properties @@ -0,0 +1,195 @@ +# + +ACCOUNT_ALREADY_EXISTS = Konto existiert bereits + +ACCOUNT_CANNOT_REWARD_SHARE = Konto kann nicht an Belohnungsbeteiligung teilnehmen + +ADDRESS_ABOVE_RATE_LIMIT = Adresse hat festgelegtes Tarif-Limit erreicht + +ADDRESS_BLOCKED = diese Adresse ist gesperrt + +ALREADY_GROUP_ADMIN = bereits Gruppenadmin + +ALREADY_GROUP_MEMBER = bereits Gruppenmitglied + +ALREADY_VOTED_FOR_THAT_OPTION = bereits für diese Option gestimmt + +ASSET_ALREADY_EXISTS = Vermögenswert existiert bereits + +ASSET_DOES_NOT_EXIST = Vermögenswert existiert nicht + +ASSET_DOES_NOT_MATCH_AT = Vermögenswert stimmt nicht mit dem Vermögenswert von AT überein + +ASSET_NOT_SPENDABLE = Vermögenswert ist nicht auszahlbar + +AT_ALREADY_EXISTS = AT existiert bereits + +AT_IS_FINISHED = AT ist beendet + +AT_UNKNOWN = AT unbekannt + +BAN_EXISTS = Bann ist bereits vorhanden + +BAN_UNKNOWN = Bann unbekannt + +BANNED_FROM_GROUP = aus der Gruppe verbannt + +BUYER_ALREADY_OWNER = Käufer ist bereits Eigentümer + +CLOCK_NOT_SYNCED = Uhrzeit ist nicht synchronisiert + +DUPLICATE_MESSAGE = Adresse hat doppelte Nachricht gesendet + +DUPLICATE_OPTION = doppelte Option + +GROUP_ALREADY_EXISTS = Gruppe existiert bereits + +GROUP_APPROVAL_DECIDED = Gruppenzulassung bereits beschlossen + +GROUP_APPROVAL_NOT_REQUIRED = Gruppenzustimmung nicht erforderlich + +GROUP_DOES_NOT_EXIST = Gruppe existiert nicht + +GROUP_ID_MISMATCH = Gruppen-ID stimmt nicht überein + +GROUP_OWNER_CANNOT_LEAVE = Gruppeneigentümer kann Gruppe nicht verlassen + +HAVE_EQUALS_WANT = Haben-Vermögenswert ist derselbe wie Wollen-Vermögenswert + +INCORRECT_NONCE = falsche PoW-Nonce + +INSUFFICIENT_FEE = unzureichende Gebühr + +INVALID_ADDRESS = ungültige Adresse + +INVALID_AMOUNT = ungültiger Betrag + +INVALID_ASSET_OWNER = ungültiger Vermögenswert-Eigentümer + +INVALID_AT_TRANSACTION = ungültige AT-Transaktion + +INVALID_AT_TYPE_LENGTH = ungültige AT-Typ-Länge + +INVALID_BUT_OK = ungültig, aber OK + +INVALID_CREATION_BYTES = ungültige Erstellungsbytes + +INVALID_DATA_LENGTH = unzulässige Datenlänge + +INVALID_DESCRIPTION_LENGTH = unzulässige Länge der Beschreibung + +INVALID_GROUP_APPROVAL_THRESHOLD = ungültiger Schwellenwert für die Gruppenzulassung + +INVALID_GROUP_BLOCK_DELAY = ungültige Blockverzögerung bei der Gruppenfreigabe + +INVALID_GROUP_ID = ungültige Gruppen-ID + +INVALID_GROUP_OWNER = ungültiger Gruppeneigentümer + +INVALID_LIFETIME = unzulässige Gültigkeitsdauer + +INVALID_NAME_LENGTH = ungültige Namenslänge + +INVALID_NAME_OWNER = ungültiger Eigentümer des Namens + +INVALID_OPTION_LENGTH = ungültige Länge der Optionen + +INVALID_OPTIONS_COUNT = ungültige Anzahl von Optionen + +INVALID_ORDER_CREATOR = ungültiger Auftragsersteller + +INVALID_PAYMENTS_COUNT = ungültige Anzahl der Zahlungen + +INVALID_PUBLIC_KEY = ungültiger öffentlicher Schlüssel + +INVALID_QUANTITY = ungültige Menge + +INVALID_REFERENCE = ungültige Referenz + +INVALID_RETURN = ungültige Rückgabe + +INVALID_REWARD_SHARE_PERCENT = ungültiger Belohnungsbeteiligungs-Anteil + +INVALID_SELLER = ungültiger Verkäufer + +INVALID_TAGS_LENGTH = ungültige 'tags'-Länge + +INVALID_TIMESTAMP_SIGNATURE = ungültige Zeitstempel-Signatur + +INVALID_TX_GROUP_ID = ungültige Transaktionsgruppen-ID + +INVALID_VALUE_LENGTH = ungültige 'value'-Länge + +INVITE_UNKNOWN = Gruppeneinladung unbekannt + +JOIN_REQUEST_EXISTS = Gruppenverbindungsanfrage existiert bereits + +MAXIMUM_REWARD_SHARES = maximale Anzahl von Belohnungsbeteiligungen für dieses Konto bereits erreicht + +MISSING_CREATOR = fehlender Ersteller + +MULTIPLE_NAMES_FORBIDDEN = mehrere registrierte Namen pro Konto sind verboten + +NAME_ALREADY_FOR_SALE = Name steht bereits zum Verkauf + +NAME_ALREADY_REGISTERED = Name bereits registriert + +NAME_BLOCKED = dieser Name ist gesperrt + +NAME_DOES_NOT_EXIST = Name existiert nicht + +NAME_NOT_FOR_SALE = Name steht nicht zum Verkauf + +NAME_NOT_NORMALIZED = Name nicht in Unicode-'normalisierter' Form + +NEGATIVE_AMOUNT = ungültiger/negativer Betrag + +NEGATIVE_FEE = ungültige/negative Gebühr + +NEGATIVE_PRICE = ungültiger/negativer Preis + +NO_BALANCE = unzureichendes Guthaben + +NO_BLOCKCHAIN_LOCK = die Blockchain des Knotens ist derzeit beschäftigt + +NO_FLAG_PERMISSION = Konto hat diese Berechtigung nicht + +NOT_GROUP_ADMIN = Konto ist kein Gruppenadmin + +NOT_GROUP_MEMBER = Konto ist kein Gruppenmitglied + +NOT_MINTING_ACCOUNT = Konto kann nicht prägen + +NOT_YET_RELEASED = Funktion noch nicht freigegeben + +OK = OK + +ORDER_ALREADY_CLOSED = Vermögenswert-Handelsauftrag ist bereits geschlossen + +ORDER_DOES_NOT_EXIST = Vermögenswert-Handelsauftrag existiert nicht + +POLL_ALREADY_EXISTS = Umfrage existiert bereits + +POLL_DOES_NOT_EXIST = Umfrage existiert nicht + +POLL_OPTION_DOES_NOT_EXIST = Umfrageoption nicht vorhanden + +PUBLIC_KEY_UNKNOWN = öffentlicher Schlüssel unbekannt + +REWARD_SHARE_UNKNOWN = Belohnungsbeteiligung unbekannt + +SELF_SHARE_EXISTS = Selbst-Beteiligung (Belohnungsbeteiligung) existiert bereits + +TIMESTAMP_TOO_NEW = Zeitstempel zu neu + +TIMESTAMP_TOO_OLD = Zeitstempel zu alt + +TOO_MANY_UNCONFIRMED = Konto hat zu viele ausstehende unbestätigte Transaktionen + +TRANSACTION_ALREADY_CONFIRMED = Transaktion wurde bereits bestätigt + +TRANSACTION_ALREADY_EXISTS = Transaktion existiert bereits + +TRANSACTION_UNKNOWN = Transaktion unbekannt + +TX_GROUP_ID_MISMATCH = die Gruppen-ID der Transaktion stimmt nicht überein From ac433b1527f7d7ae5abed9f52fd121eb7321df4b Mon Sep 17 00:00:00 2001 From: crowetic <5431064+crowetic@users.noreply.github.com> Date: Tue, 27 Jun 2023 13:29:10 -0700 Subject: [PATCH 02/57] Add files via upload --- tools/qdn | 122 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 105 insertions(+), 17 deletions(-) diff --git a/tools/qdn b/tools/qdn index ea52e3c9..adbe861d 100755 --- a/tools/qdn +++ b/tools/qdn @@ -5,10 +5,10 @@ host="localhost" port=12391 if [ -z "$*" ]; then - echo "Usage:" + echo "Usage:" echo echo "Host/update data:" - echo "qdn POST [service] [name] PATH [dirpath] " + echo "qdn POST [service] [name] PATH [dirpath] <description> <tags=tag1,tag2,tag3> <category> <fee> <preview (true or false)>" echo "qdn POST [service] [name] STRING [data-string] <identifier>" echo echo "Fetch data:" @@ -22,6 +22,21 @@ if [ -z "$*" ]; then exit fi + +# Default ports for Qortal +mainnet_port=12391 +testnet_port=62391 + +# Check if the '-t' operator is passed, if so change to utilizing testnet. +if [[ "$1" == "-t" ]]; then + # Use testnet port + port=$testnet_port + shift +else + # Use mainnet port + port=$mainnet_port +fi + script_dir=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) if [ -f "apikey.txt" ]; then @@ -37,32 +52,46 @@ service=$2 name=$3 if [ -z "${method}" ]; then - echo "Error: missing method"; exit + echo "Error: missing method" + exit 1 fi if [ -z "${service}" ]; then - echo "Error: missing service"; exit + echo "Error: missing service" + exit 1 fi if [ -z "${name}" ]; then - echo "Error: missing name"; exit + echo "Error: missing name" + exit 1 fi - if [[ "${method}" == "POST" ]]; then type=$4 data=$5 identifier=$6 + title=$7 + description=$8 + tags=$9 + category=${10} + fee=${11} + preview=${12} + + if [ -z "${data}" ]; then if [[ "${type}" == "PATH" ]]; then - echo "Error: missing directory"; exit + echo "Error: missing directory - please use a path to a directory with a SINGLE file wishing to be published" + exit 1 elif [[ "${type}" == "STRING" ]]; then - echo "Error: missing data string"; exit + echo "Error: missing data string - please input the data string you wish to publish" + exit 1 else - echo "Error: unrecognized type"; exit + echo "Error: unrecognized type" + exit 1 fi fi if [ -z "${QORTAL_PRIVKEY}" ]; then - echo "Error: missing private key. Set it by running: export QORTAL_PRIVKEY=privkeyhere"; exit + echo "Error: missing private key. Set it by running: export QORTAL_PRIVKEY=privkeyhere" + exit 1 fi if [ -z "${identifier}" ]; then @@ -75,30 +104,88 @@ if [[ "${method}" == "POST" ]]; then elif [[ "${type}" == "STRING" ]]; then type_component="/string" fi + + # Create tags component in URL, comma-separated list of tags, will be added to the tags call. + tags_component="" + if [ -n "${tags}" ]; then + IFS=',' read -ra tag_array <<< "${tags}" + for tag in "${tag_array[@]}"; do + tags_component+="&tags=${tag}" + done + fi + + if [ -z ${tags_component} ]; then + tags_component="" + echo "nothing in tags, using empty tags" + fi + + #Create category component with pre-defined list of categories. Error if category is specified but not in list. + allowed_categories=("ART" "AUTOMOTIVE" "BEAUTY" "BOOKS" "BUSINESS" "COMMUNICATIONS" "CRYPTOCURRENCY" "CULTURE" "DATING" "DESIGN" "ENTERTAINMENT" "EVENTS" "FAITH" "FASHION" "FINANCE" "FOOD" "GAMING" "GEOGRAPHY" "HEALTH" "HISTORY" "HOME" "KNOWLEDGE" "LANGUAGE" "LIFESTYLE" "MANUFACTURING" "MAPS" "MUSIC" "NEWS" "OTHER" "PETS" "PHILOSOPHY" "PHOTOGRAPHY" "POLITICS" "PRODUCE" "PRODUCTIVITY" "PSYCHOLOGY" "QORTAL" "SCIENCE" "SELF_CARE" "SELF_SUFFICIENCY" "SHOPPING" "SOCIAL" "SOFTWARE" "SPIRITUALITY" "SPORTS" "STORYTELLING" "TECHNOLOGY" "TOOLS" "TRAVEL" "UNCATEGORIZED" "VIDEO" "WEATHER") + + if [[ -n "$category" && ! " ${allowed_categories[@]} " =~ " $category " ]]; then + echo "Error: Invalid category. Allowed categories are: ${allowed_categories[*]} be sure to place your overall script inputs in the correct order" + exit 1 + elif [ -z "$category" ]; then + category="" + echo "No category is being set" + fi + + if [ -n "$fee" ]; then + if [[ "$fee" == "1" || "$fee" == ".001" ]]; then + fee="100000" + elif [ -z "$fee" ]; then + fee="" + else + echo "Error: Invalid fee value. Expected '1', '.001' or no input." + exit 1 + fi + final_fee="${fee}" + fi + + + # check that preview is true/false + if [[ -n "$preview" && ! ( "$preview" == "true" || "$preview" == "false" ) ]]; then + echo "Error: Invalid preview value. Expected 'true' or 'false'. Please retry with boolean as preview entry." + exit 1 + elif [ -z "$preview" ]; then + preview="" + fi + + # Build the API URL + api_url="http://${host}:${port}/arbitrary/${service}/${name}/${identifier}${type_component}" + api_url+="?title=${title}&description=${description}&tags=${tags_component}&category=${category}&fee=${final_fee}&preview=${preview}" + echo "Creating transaction - this can take a while..." - tx_data=$(curl --silent --insecure -X ${method} "http://${host}:${port}/arbitrary/${service}/${name}/${identifier}${type_component}" -H "X-API-KEY: ${apikey}" -d "${data}") + tx_data=$(curl --silent --insecure -X ${method} "${api_url}" -H "accept: text/plain" -H "X-API-KEY: ${apikey}" -H "Content-Type: text/plain" -d "${data}") if [[ "${tx_data}" == *"error"* || "${tx_data}" == *"ERROR"* ]]; then - echo "${tx_data}"; exit + echo "Error creating transaction: ${tx_data}" + exit 1 elif [ -z "${tx_data}" ]; then - echo "Error: no transaction data returned"; exit + echo "Error: no transaction data returned" + exit 1 fi echo "Computing nonce..." computed_tx_data=$(curl --silent --insecure -X POST "http://${host}:${port}/arbitrary/compute" -H "Content-Type: application/json" -H "X-API-KEY: ${apikey}" -d "${tx_data}") + if [[ "${computed_tx_data}" == *"error"* || "${computed_tx_data}" == *"ERROR"* ]]; then - echo "${computed_tx_data}"; exit + echo "Error computing nonce: ${computed_tx_data}" + exit 1 fi echo "Signing..." signed_tx_data=$(curl --silent --insecure -X POST "http://${host}:${port}/transactions/sign" -H "Content-Type: application/json" -d "{\"privateKey\":\"${QORTAL_PRIVKEY}\",\"transactionBytes\":\"${computed_tx_data}\"}") + if [[ "${signed_tx_data}" == *"error"* || "${signed_tx_data}" == *"ERROR"* ]]; then - echo "${signed_tx_data}"; exit + echo "Error signing transaction: ${signed_tx_data}" + exit 1 fi echo "Broadcasting..." success=$(curl --silent --insecure -X POST "http://${host}:${port}/transactions/process" -H "Content-Type: text/plain" -d "${signed_tx_data}") + if [[ "${success}" == "true" ]]; then echo "Transaction broadcast successfully" else @@ -131,9 +218,10 @@ elif [[ "${method}" == "GET" ]]; then echo "Empty response from ${host}:${port}" fi if [[ "${response}" == *"error"* || "${response}" == *"ERROR"* ]]; then - echo "${response}"; exit + echo "${response}" + exit 1 fi echo "${response}" - fi + From cc8cdcd93af34eddd8dc4f738df230b17f897413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrg=20Schulthess?= <schulthess@puzzle.ch> Date: Mon, 3 Jul 2023 09:52:07 +0200 Subject: [PATCH 03/57] Fix website sub-folder rendering 404 --- .../java/org/qortal/arbitrary/ArbitraryDataRenderer.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 704533c8..5c6cda63 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -127,6 +127,11 @@ public class ArbitraryDataRenderer { String filename = this.getFilename(unzippedPath, inPath); 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)) { From fe840bbf0282d85a1e3dae1a0b25245eec08233a Mon Sep 17 00:00:00 2001 From: kennycud <kennycud@protonmail.com> Date: Tue, 8 Aug 2023 12:17:29 -0700 Subject: [PATCH 04/57] consolidated shared functionality into BitcoinyTests.java --- .../qortal/test/crosschain/BitcoinTests.java | 126 +++++----------- .../qortal/test/crosschain/BitcoinyTests.java | 130 +++++++++++++++++ .../qortal/test/crosschain/DigibyteTests.java | 119 ++++----------- .../qortal/test/crosschain/DogecoinTests.java | 120 ++++------------ .../qortal/test/crosschain/LitecoinTests.java | 120 ++++------------ .../test/crosschain/PirateChainTests.java | 135 ++++++------------ .../test/crosschain/RavencoinTests.java | 120 ++++------------ 7 files changed, 306 insertions(+), 564 deletions(-) create mode 100644 src/test/java/org/qortal/test/crosschain/BitcoinyTests.java diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java index 1096d7ad..684f1cd6 100644 --- a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java @@ -1,121 +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 @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - 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); - } + public void testGetMedianBlockTime() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - 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)); - } + public void testFindHtlcSecret() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - 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); - } + public void testBuildSpend() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - 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); - } + public void testGetWalletBalance() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String address = bitcoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + 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/LitecoinTests.java b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java index 5ea7bc95..66c631e5 100644 --- a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java @@ -1,113 +1,43 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -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 ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Litecoin median blocktime: %d", litecoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + } - System.out.println(String.format("Litecoin 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<CompactBlock> compactBlocks = pirateChain.getCompactBlocks(startHeight, count); + List<CompactBlock> 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<BitcoinyTransaction> transactions = pirateChain.getBlockchainProvider() + List<BitcoinyTransaction> 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() {} } From d9147b3af39d5f4d063a9e7521fd8195154d61fa Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 10:24:55 +0100 Subject: [PATCH 05/57] Cache the online accounts validation result, to speed up block minting. --- src/main/java/org/qortal/block/Block.java | 17 +++++++++++++++++ .../java/org/qortal/controller/BlockMinter.java | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index e030e6a6..cf227d4a 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -130,6 +130,9 @@ 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<String, Long> balanceChanges) throws DataException; @@ -563,6 +566,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 /** @@ -1043,6 +1053,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 @@ -1130,6 +1144,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; } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index b1ed7e3c..35c89778 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -562,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) From 897c44ffe87843c839deb1bb71be7544717cdb35 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 11:14:21 +0100 Subject: [PATCH 06/57] Optimized transaction importing by using a temporary cache of unconfirmed transactions. --- .../org/qortal/controller/TransactionImporter.java | 12 ++++++++++++ .../java/org/qortal/transaction/Transaction.java | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index 5c70f369..4f3b6f2f 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<String, Long> invalidUnconfirmedTransactions = Collections.synchronizedMap(new HashMap<>()); + /** Cached list of unconfirmed transactions, used when counting per creator. This is replaced regularly */ + public static List<TransactionData> unconfirmedTransactionsCache = null; + public static synchronized TransactionImporter getInstance() { if (instance == null) { @@ -254,6 +257,12 @@ 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<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + unconfirmedTransactions.removeIf(t -> t.getType() == Transaction.TransactionType.CHAT); + unconfirmedTransactionsCache = unconfirmedTransactions; + // Import transactions with valid signatures try { for (int i = 0; i < sigValidTransactions.size(); ++i) { @@ -317,6 +326,9 @@ public class TransactionImporter extends Thread { } 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/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index bd91f25a..a9eb6ae7 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -13,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; @@ -617,7 +618,10 @@ public abstract class Transaction { } private int countUnconfirmedByCreator(PublicKeyAccount creator) throws DataException { - List<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + List<TransactionData> 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 From 278dca75e81ca0edb601def0ccec7f6b82f9d476 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 15:18:29 +0100 Subject: [PATCH 07/57] Increase minimum fee at a future undecided timestamp. --- .../java/org/qortal/block/BlockChain.java | 21 +-- .../org/qortal/transaction/Transaction.java | 2 +- src/main/resources/blockchain.json | 6 +- .../java/org/qortal/test/MessageTests.java | 2 +- .../common/transaction/TestTransaction.java | 4 +- .../org/qortal/test/naming/BuySellTests.java | 2 +- .../org/qortal/test/naming/MiscTests.java | 132 +++++++++++++++++- .../test-chain-v2-block-timestamps.json | 5 +- .../test-chain-v2-disable-reference.json | 5 +- .../test-chain-v2-founder-rewards.json | 5 +- .../test-chain-v2-leftover-reward.json | 5 +- src/test/resources/test-chain-v2-minting.json | 5 +- .../test-chain-v2-qora-holder-extremes.json | 5 +- .../test-chain-v2-qora-holder-reduction.json | 5 +- .../resources/test-chain-v2-qora-holder.json | 5 +- .../test-chain-v2-reward-levels.json | 5 +- .../test-chain-v2-reward-scaling.json | 5 +- .../test-chain-v2-reward-shares.json | 5 +- .../test-chain-v2-self-sponsorship-algo.json | 5 +- src/test/resources/test-chain-v2.json | 8 +- 20 files changed, 205 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 218fb14d..d79203da 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. */ @@ -89,6 +86,7 @@ public class BlockChain { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long fee; } + private List<UnitFeesByTimestamp> unitFees; private List<UnitFeesByTimestamp> nameRegistrationUnitFees; /** Map of which blockchain features are enabled when (height/timestamp) */ @@ -346,10 +344,6 @@ public class BlockChain { return this.isTestChain; } - public long getUnitFee() { - return this.unitFee; - } - public int getMaxBytesPerUnitFee() { return this.maxBytesPerUnitFee; } @@ -547,13 +541,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) { diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index a9eb6ae7..f750aff5 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -378,7 +378,7 @@ public abstract class Transaction { * @return */ public long getUnitFee(Long timestamp) { - return BlockChain.getInstance().getUnitFee(); + return BlockChain.getInstance().getUnitFeeAtTimestamp(timestamp); } /** diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index c6151204..7d203004 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": 9999999999999, "fee": "0.01" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.001" }, { "timestamp": 1645372800000, "fee": "5" }, { "timestamp": 1651420800000, "fee": "1.25" } ], diff --git a/src/test/java/org/qortal/test/MessageTests.java b/src/test/java/org/qortal/test/MessageTests.java index f08c7b2f..4d0ecfcc 100644 --- a/src/test/java/org/qortal/test/MessageTests.java +++ b/src/test/java/org/qortal/test/MessageTests.java @@ -85,7 +85,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"); 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/naming/BuySellTests.java b/src/test/java/org/qortal/test/naming/BuySellTests.java index 4530820e..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 diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 2bcd098d..91eb3dc9 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,123 @@ 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"); + + 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(); + assertTrue("Transaction should be valid", ValidationResult.OK == result); + + // Now try fetching and setting fee manually + transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.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(); + assertTrue("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/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 3b4de702..25915e9a 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, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index c93fbb78..ae499491 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, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 1b068932..e6b327b1 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, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index aef76cc2..9fda9b1f 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, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index db6d8a0b..4ff3ec3c 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, 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 2452d4d2..306fd9a3 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, 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 23193729..6128baa7 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, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 9d81632b..9b6bccda 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, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 81609595..1ffa8baf 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, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 21a5b7a7..6c3b3276 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, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 6119ac48..09627e1f 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, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index dc5f3961..81cda1e8 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 0, - "unitFee": "0.00000001", + "unitFees": [ + { "timestamp": 0, "fee": "0.00000001" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.00000001" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index d0c460df..2e96e911 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, From e244a5f882552313b960301b0b0800ed0971d3e4 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 16:09:41 +0100 Subject: [PATCH 08/57] Removed legacy code that is no longer needed. --- .../java/org/qortal/controller/Synchronizer.java | 16 +++------------- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 2dad62e7..804bacbb 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -229,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()) @@ -262,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) @@ -1340,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) { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index ac9b8857..8d4eab56 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -227,7 +227,7 @@ public class Settings { private int maxRetries = 2; /** The number of seconds of no activity before recovery mode begins */ - public long recoveryModeTimeout = 24 * 60 * 60 * 1000L; + public long recoveryModeTimeout = 9999999999999L; /** Minimum peer version number required in order to sync with them */ private String minPeerVersion = "4.1.2"; From 640a4728764aab34cb11bc48f3a877b1393a18af Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 18:55:27 +0100 Subject: [PATCH 09/57] Unit fee increase set to 1692118800000 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 7d203004..1cf4f010 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -5,7 +5,7 @@ "maxBytesPerUnitFee": 1024, "unitFees": [ { "timestamp": 0, "fee": "0.001" }, - { "timestamp": 9999999999999, "fee": "0.01" } + { "timestamp": 1692118800000, "fee": "0.01" } ], "nameRegistrationUnitFees": [ { "timestamp": 0, "fee": "0.001" }, From eea288411215cc38e75269bd6abef7809ee2a901 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 18:59:00 +0100 Subject: [PATCH 10/57] Bump version to 4.2.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 30ad1e04..b18cd8ae 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ <modelVersion>4.0.0</modelVersion> <groupId>org.qortal</groupId> <artifactId>qortal</artifactId> - <version>4.2.2</version> + <version>4.2.3</version> <packaging>jar</packaging> <properties> <skipTests>true</skipTests> From dd9d3fdb22ded705260016ca4e9427fe6da3fd59 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 19:27:19 +0100 Subject: [PATCH 11/57] Add to the unconfirmed transactions cache when importing a transaction. --- src/main/java/org/qortal/controller/TransactionImporter.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index 4f3b6f2f..fa35cbbe 100644 --- a/src/main/java/org/qortal/controller/TransactionImporter.java +++ b/src/main/java/org/qortal/controller/TransactionImporter.java @@ -295,6 +295,11 @@ 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); + } break; } From ecfb9a7d6dc511db3f0d2f28dc763b759bf89135 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 12 Aug 2023 19:34:59 +0100 Subject: [PATCH 12/57] Bump version to 4.2.4 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b18cd8ae..5ba103e6 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ <modelVersion>4.0.0</modelVersion> <groupId>org.qortal</groupId> <artifactId>qortal</artifactId> - <version>4.2.3</version> + <version>4.2.4</version> <packaging>jar</packaging> <properties> <skipTests>true</skipTests> From 6bf2b999136c85c94ba08ba6723c6a0646fcce5a Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 18 Aug 2023 15:37:24 +0100 Subject: [PATCH 13/57] Wait until unconfirmed transactions are considered to be valid before broadcasting them. --- .../controller/TransactionImporter.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index fa35cbbe..6c846f3b 100644 --- a/src/main/java/org/qortal/controller/TransactionImporter.java +++ b/src/main/java/org/qortal/controller/TransactionImporter.java @@ -218,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); } @@ -263,6 +257,9 @@ public class TransactionImporter extends Thread { unconfirmedTransactions.removeIf(t -> t.getType() == Transaction.TransactionType.CHAT); unconfirmedTransactionsCache = unconfirmedTransactions; + // A list of signatures were imported in this round + List<byte[]> newlyImportedSignatures = new ArrayList<>(); + // Import transactions with valid signatures try { for (int i = 0; i < sigValidTransactions.size(); ++i) { @@ -300,6 +297,10 @@ public class TransactionImporter extends Thread { if (transactionData.getType() != Transaction.TransactionType.CHAT && unconfirmedTransactionsCache != null) { unconfirmedTransactionsCache.add(transactionData); } + + // Signature imported in this round + newlyImportedSignatures.add(transactionData.getSignature()); + break; } @@ -328,6 +329,12 @@ 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(); From b0224651c2b030ec68a80c25127d38eb8c500596 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 18 Aug 2023 20:32:44 +0100 Subject: [PATCH 14/57] Always use rate limiter for metadata requests, and sleep for a random amount of time between fetching metadata items. --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 2 +- .../qortal/controller/arbitrary/ArbitraryDataManager.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index c617b517..e7a20d0e 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -724,7 +724,7 @@ public class ArbitraryResource { 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, true); if (resourceMetadata != null) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java index 9284e672..470fbda9 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java @@ -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()) { From 7bb61ec564a49808c07859ba5c9e42f9f7872a83 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 19 Aug 2023 13:57:26 +0100 Subject: [PATCH 15/57] Update various transaction types at a future unknown timestamp. - PUBLICIZE transactions are no longer possible. - ARBITRARY transactions are now only possible using a fee. - MESSAGE transactions only confirm when they are being sent to an AT. Messages to regular addresses (or no recipient) will expire after 24 hours. - Difficulty for confirmed MESSAGE transactions increases from 14 to 16. - Difficulty for unconfirmed MESSAGE transactions decreases from 14 to 12. --- src/main/java/org/qortal/block/Block.java | 8 + .../java/org/qortal/block/BlockChain.java | 8 + .../transaction/ArbitraryTransaction.java | 20 +- .../qortal/transaction/ChatTransaction.java | 6 + .../transaction/MessageTransaction.java | 40 +++- .../transaction/PresenceTransaction.java | 6 + .../transaction/PublicizeTransaction.java | 7 + .../org/qortal/transaction/Transaction.java | 19 +- src/main/resources/blockchain.json | 1 + .../java/org/qortal/test/MemoryPoWTests.java | 9 +- .../java/org/qortal/test/MessageTests.java | 225 +++++++++++++++--- .../ArbitraryTransactionMetadataTests.java | 17 +- .../arbitrary/ArbitraryTransactionTests.java | 55 +---- .../qortal/test/common/ArbitraryUtils.java | 11 +- .../org/qortal/test/naming/MiscTests.java | 7 +- .../test-chain-v2-block-timestamps.json | 1 + .../test-chain-v2-disable-reference.json | 1 + .../test-chain-v2-founder-rewards.json | 1 + .../test-chain-v2-leftover-reward.json | 1 + src/test/resources/test-chain-v2-minting.json | 1 + .../test-chain-v2-qora-holder-extremes.json | 1 + .../test-chain-v2-qora-holder-reduction.json | 1 + .../resources/test-chain-v2-qora-holder.json | 1 + .../test-chain-v2-reward-levels.json | 1 + .../test-chain-v2-reward-scaling.json | 1 + .../test-chain-v2-reward-shares.json | 1 + .../test-chain-v2-self-sponsorship-algo.json | 1 + src/test/resources/test-chain-v2.json | 1 + 28 files changed, 341 insertions(+), 111 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index cf227d4a..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), @@ -1251,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; diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index d79203da..540e6cf4 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -209,6 +209,9 @@ public class BlockChain { /** 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 { public long timestamp; @@ -370,6 +373,11 @@ public class BlockChain { 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. */ public boolean getRequireGroupForApproval() { return this.requireGroupForApproval; diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index 7034d7b8..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,6 @@ 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; @@ -88,8 +86,14 @@ public class ArbitraryTransaction extends Transaction { if (this.transactionData.getFee() < 0) return ValidationResult.NEGATIVE_FEE; - // After the feature trigger, we require the fee to be sufficient if it's not 0. - // If the fee is zero, then the nonce is validated in isSignatureValid() as an alternative to a 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(); } @@ -214,7 +218,13 @@ public class ArbitraryTransaction extends Transaction { // Clear nonce from transactionBytes ArbitraryTransactionTransformer.clearNonce(transactionBytes); - // As of feature-trigger timestamp, we only require a nonce when the fee is zero + // 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 diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index f6e26802..3d968461 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -148,6 +148,12 @@ 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 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 76fef00b..44f93e6e 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -7,6 +7,7 @@ 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; @@ -89,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<byte[]> signatures = this.repository.getTransactionRepository().getSignaturesMatchingCriteria( TransactionType.PUBLICIZE, diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index f750aff5..e0ed1f82 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -248,7 +248,8 @@ public abstract class Transaction { 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; @@ -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 @@ -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(); } @@ -892,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. * <p> diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 1cf4f010..996ae2c1 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -29,6 +29,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, + "mempowTransactionUpdatesTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, 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 4d0ecfcc..3e549235 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; @@ -139,7 +137,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 +151,132 @@ 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"); + + 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()); + + 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 +289,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 +337,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 +393,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/arbitrary/ArbitraryTransactionMetadataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java index 47c68b25..9ac73166 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java @@ -11,6 +11,7 @@ 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; @@ -24,6 +25,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.NTP; import java.io.IOException; import java.nio.file.Files; @@ -106,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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -157,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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -220,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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -272,8 +277,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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -316,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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the metadata is correct diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java index 855aeafd..089f0475 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java @@ -10,6 +10,7 @@ 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; @@ -50,51 +51,6 @@ public class ArbitraryTransactionTests extends Common { Common.useDefaultSettings(); } - @Test - public void testDifficultyTooLow() 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 - Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); - ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); - - // 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 - // 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 testNonceAndFee() throws IllegalAccessException, DataException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -497,8 +453,9 @@ public class ArbitraryTransactionTests extends Common { // 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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, null, null, null, null); byte[] signature = arbitraryDataFile.getSignature(); @@ -556,8 +513,9 @@ public class ArbitraryTransactionTests extends Common { // 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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); byte[] signature = arbitraryDataFile.getSignature(); @@ -614,8 +572,9 @@ public class ArbitraryTransactionTests extends Common { // 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, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, null, null, null, null); byte[] signature = arbitraryDataFile.getSignature(); diff --git a/src/test/java/org/qortal/test/common/ArbitraryUtils.java b/src/test/java/org/qortal/test/common/ArbitraryUtils.java index 1741d22c..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,16 +22,15 @@ 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, 0L, true, 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, @@ -47,7 +48,9 @@ public class ArbitraryUtils { } 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 diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 91eb3dc9..401b03b9 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -444,6 +444,7 @@ public class MiscTests extends Common { // 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())); @@ -473,16 +474,16 @@ public class MiscTests extends Common { Transaction transaction = Transaction.fromData(repository, transactionData); transaction.sign(alice); ValidationResult result = transaction.importAsUnconfirmed(); - assertTrue("Transaction should be valid", ValidationResult.OK == result); + assertEquals("Transaction should be valid", ValidationResult.OK, result); // Now try fetching and setting fee manually - transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 50000); + 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(); - assertTrue("Transaction should be valid", ValidationResult.OK == result); + assertEquals("Transaction should be valid", ValidationResult.OK, result); } } diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 25915e9a..7059e035 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -18,6 +18,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index ae499491..1016bc17 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -22,6 +22,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index e6b327b1..5f29bc97 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index 9fda9b1f..86f2def1 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 4ff3ec3c..b2da6489 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, 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 306fd9a3..2933a63d 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, 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 6128baa7..40e40673 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 9b6bccda..8ceafe63 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 1ffa8baf..68a79ed4 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 6c3b3276..cc02a73e 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 09627e1f..5c508188 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -22,6 +22,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index 81cda1e8..244d2491 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 2e96e911..9168a0de 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -24,6 +24,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, From 379b850bbd3be835c497bff43a28de81d134c766 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 19 Aug 2023 14:12:12 +0100 Subject: [PATCH 16/57] Increase default maxTransactionsPerBlock to 50 now that the process is faster. Can be increased much further in the future. --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 8d4eab56..ab1936c7 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -147,7 +147,7 @@ public class Settings { private int blockCacheSize = 10; /** Maximum number of transactions for the block minter to include in a block */ - private int maxTransactionsPerBlock = 25; + private int maxTransactionsPerBlock = 50; /** How long to keep old, full, AT state data (ms). */ private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds From 1f7a60dfd852fe3828b9ad1d647c92b1b5f6676e Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 19 Aug 2023 20:27:05 +0100 Subject: [PATCH 17/57] Fixed long term issue preventing trade bot statuses from being logged correctly. --- src/main/java/org/qortal/controller/tradebot/TradeBot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 96eeaf36..147554dd 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -333,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)); From 4feb8f46c81913926eeb7e942825db72f9c609f8 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sat, 19 Aug 2023 20:42:15 +0100 Subject: [PATCH 18/57] Updated testchain.json to use new unitFees structure. --- testnet/testchain.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testnet/testchain.json b/testnet/testchain.json index aef9ed9a..3b31c3e3 100644 --- a/testnet/testchain.json +++ b/testnet/testchain.json @@ -4,7 +4,9 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.001", + "unitFees": [ + { "timestamp": 0, "fee": "0.001" } + ], "nameRegistrationUnitFees": [ { "timestamp": 0, "fee": "1.25" } ], From 3c8574a466989e63b2d7eeada15d429e1aded7a2 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sun, 20 Aug 2023 12:42:49 +0100 Subject: [PATCH 19/57] Trade bot improvements - Add async responder thread from @catbref which was previously only in place for LTC - Log when computing mempow nonces - Skip transaction import if signature is invalid - Added checks to a message test to mimic trade bot transaction lookups --- .../tradebot/BitcoinACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/DigibyteACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/DogecoinACCTv1TradeBot.java | 71 ++++++++++++++----- .../tradebot/DogecoinACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/LitecoinACCTv3TradeBot.java | 47 +++++++++--- .../tradebot/PirateChainACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/RavencoinACCTv3TradeBot.java | 71 ++++++++++++++----- .../java/org/qortal/test/MessageTests.java | 9 +++ 8 files changed, 371 insertions(+), 111 deletions(-) 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 a4ae921e..b5631f0b 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java @@ -325,15 +325,24 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, 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 threadsRepository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + 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())); @@ -556,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; } } @@ -676,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/test/java/org/qortal/test/MessageTests.java b/src/test/java/org/qortal/test/MessageTests.java index 3e549235..c76c715e 100644 --- a/src/test/java/org/qortal/test/MessageTests.java +++ b/src/test/java/org/qortal/test/MessageTests.java @@ -29,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 { @@ -173,6 +174,10 @@ public class MessageTests extends Common { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + // Transaction should not be present in db yet + List<MessageTransactionData> 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 @@ -181,6 +186,10 @@ public class MessageTests extends Common { 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); } } From 086a9afa0ee0e9a0bc9f88f0b07583b277ccd8a1 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Sun, 20 Aug 2023 16:52:49 +0100 Subject: [PATCH 20/57] Testnet mempowTransactionUpdatesTimestamp set to 1692554400000 --- testnet/testchain.json | 1 + 1 file changed, 1 insertion(+) diff --git a/testnet/testchain.json b/testnet/testchain.json index 3b31c3e3..089bd693 100644 --- a/testnet/testchain.json +++ b/testnet/testchain.json @@ -26,6 +26,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 0, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 1692554400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 34382b6e69b5587caa42e7d3f14791e75b50e79b Mon Sep 17 00:00:00 2001 From: Phillip Lang Martinez <philliplangmartinez@gmail.com> Date: Tue, 22 Aug 2023 19:58:24 -0500 Subject: [PATCH 21/57] add endpoint --- .../org/qortal/api/resource/AtResource.java | 36 +++++++++++ .../data/transaction/CreationRequest.java | 63 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/main/java/org/qortal/data/transaction/CreationRequest.java diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index 29a2344d..4a5fbbdb 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.data.transaction.CreationRequest; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -156,6 +157,41 @@ public class AtResource { } } + @POST + @Path("/createMachineState") + @Operation( + summary = "Create MachineState bytes from the provided parameters", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CreationRequest.class + ) + ) + ), + responses = { + @ApiResponse( + description = "MachineState bytes", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + public byte[] createMachineState(CreationRequest request) { + return MachineState.toCreationBytes( + request.getCiyamAtVersion(), + request.getCodeBytes(), + request.getDataBytes(), + request.getNumCallStackPages(), + request.getNumUserStackPages(), + request.getMinActivationAmount() + ); + } @POST @Operation( summary = "Build raw, unsigned, DEPLOY_AT transaction", diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java new file mode 100644 index 00000000..e5b6ffdb --- /dev/null +++ b/src/main/java/org/qortal/data/transaction/CreationRequest.java @@ -0,0 +1,63 @@ +package org.qortal.data.transaction; + +public class CreationRequest { + + private short ciyamAtVersion; + private byte[] codeBytes; + private byte[] dataBytes; + private short numCallStackPages; + private short numUserStackPages; + private long minActivationAmount; + + // Default constructor for JSON deserialization + public CreationRequest() {} + + // Getters and setters + public short getCiyamAtVersion() { + return ciyamAtVersion; + } + + public void setCiyamAtVersion(short ciyamAtVersion) { + this.ciyamAtVersion = ciyamAtVersion; + } + + public byte[] getCodeBytes() { + return codeBytes; + } + + public void setCodeBytes(byte[] codeBytes) { + this.codeBytes = codeBytes; + } + + public byte[] getDataBytes() { + return dataBytes; + } + + public void setDataBytes(byte[] dataBytes) { + this.dataBytes = dataBytes; + } + + 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; + } +} \ No newline at end of file From 24ff3ab58174e3f31232f6ddf0fee60d9ce7c9a5 Mon Sep 17 00:00:00 2001 From: Phillip Lang Martinez <philliplangmartinez@gmail.com> Date: Tue, 22 Aug 2023 22:46:58 -0500 Subject: [PATCH 22/57] base64 to byte array --- .../org/qortal/api/resource/AtResource.java | 1 + .../data/transaction/CreationRequest.java | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index 4a5fbbdb..fd045b7d 100644 --- a/src/main/java/org/qortal/api/resource/AtResource.java +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -38,6 +38,7 @@ import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.Base58; +import java.util.Base64; @Path("/at") @Tag(name = "Automated Transactions") diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java index e5b6ffdb..efdfc738 100644 --- a/src/main/java/org/qortal/data/transaction/CreationRequest.java +++ b/src/main/java/org/qortal/data/transaction/CreationRequest.java @@ -1,10 +1,12 @@ package org.qortal.data.transaction; -public class CreationRequest { +import java.util.Base64; + +public class CreationRequest { private short ciyamAtVersion; - private byte[] codeBytes; - private byte[] dataBytes; + private String codeBytesBase64; + private String dataBytesBase64; private short numCallStackPages; private short numUserStackPages; private long minActivationAmount; @@ -22,19 +24,19 @@ public class CreationRequest { } public byte[] getCodeBytes() { - return codeBytes; + return Base64.getDecoder().decode(this.codeBytesBase64); } - public void setCodeBytes(byte[] codeBytes) { - this.codeBytes = codeBytes; + public void setCodeBytesBase64(String codeBytesBase64) { + this.codeBytesBase64 = codeBytesBase64; } public byte[] getDataBytes() { - return dataBytes; + return Base64.getDecoder().decode(this.dataBytesBase64); } - public void setDataBytes(byte[] dataBytes) { - this.dataBytes = dataBytes; + public void setDataBytesBase64(String dataBytesBase64) { + this.dataBytesBase64 = dataBytesBase64; } public short getNumCallStackPages() { @@ -60,4 +62,4 @@ public class CreationRequest { public void setMinActivationAmount(long minActivationAmount) { this.minActivationAmount = minActivationAmount; } -} \ No newline at end of file +} From b051f9be89422391deaaf85aa7666d5bbc766070 Mon Sep 17 00:00:00 2001 From: Phillip Lang Martinez <philliplangmartinez@gmail.com> Date: Wed, 23 Aug 2023 14:41:50 -0500 Subject: [PATCH 23/57] fix strings in request --- .../org/qortal/api/resource/AtResource.java | 36 +++++++++++++------ .../data/transaction/CreationRequest.java | 19 ++++++++-- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index fd045b7d..80a6e299 100644 --- a/src/main/java/org/qortal/api/resource/AtResource.java +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -9,6 +9,7 @@ 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.util.List; import javax.servlet.http.HttpServletRequest; @@ -39,10 +40,13 @@ import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.Base58; import java.util.Base64; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.databind.ObjectMapper; @Path("/at") @Tag(name = "Automated Transactions") public class AtResource { + private static final Logger logger = LoggerFactory.getLogger(AtResource.class); @Context HttpServletRequest request; @@ -183,15 +187,27 @@ public class AtResource { ) } ) - public byte[] createMachineState(CreationRequest request) { - return MachineState.toCreationBytes( - request.getCiyamAtVersion(), - request.getCodeBytes(), - request.getDataBytes(), - request.getNumCallStackPages(), - request.getNumUserStackPages(), - request.getMinActivationAmount() - ); + public String createMachineState(String jsonBody) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + CreationRequest request = objectMapper.readValue(jsonBody, CreationRequest.class); + + logger.info("ciyamAtVersion: {}", request.getCiyamAtVersion()); + logger.info("codeBytes: {}", request.getCodeBytes()); + logger.info("codeBytes: {}", request.getNumUserStackPages()); + logger.info("codeBytes: {}", request.getDataBytes()); + logger.info("codeBytes: {}", request.getNumCallStackPages()); + logger.info("codeBytes: {}", request.getMinActivationAmount()); + byte[] creationBytes = MachineState.toCreationBytes( + request.getCiyamAtVersion(), + request.getCodeBytes(), + request.getDataBytes(), + request.getNumCallStackPages(), + request.getNumUserStackPages(), + request.getMinActivationAmount() + ); + return Base58.encode(creationBytes); + + } @POST @Operation( diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java index efdfc738..9d50458c 100644 --- a/src/main/java/org/qortal/data/transaction/CreationRequest.java +++ b/src/main/java/org/qortal/data/transaction/CreationRequest.java @@ -1,10 +1,11 @@ package org.qortal.data.transaction; import java.util.Base64; - +import com.fasterxml.jackson.annotation.JsonProperty; public class CreationRequest { private short ciyamAtVersion; + @JsonProperty("codeBytesBase64") private String codeBytesBase64; private String dataBytesBase64; private short numCallStackPages; @@ -24,15 +25,27 @@ public class CreationRequest { } public byte[] getCodeBytes() { - return Base64.getDecoder().decode(this.codeBytesBase64); + if (this.codeBytesBase64 != null) { + return Base64.getDecoder().decode(this.codeBytesBase64); + } + return new byte[0]; } public void setCodeBytesBase64(String codeBytesBase64) { this.codeBytesBase64 = codeBytesBase64; } + public String getCodeBytes2() { + return codeBytesBase64; + + } + + public byte[] getDataBytes() { - return Base64.getDecoder().decode(this.dataBytesBase64); + if (this.dataBytesBase64 != null) { + return Base64.getDecoder().decode(this.dataBytesBase64); + } + return new byte[0]; } public void setDataBytesBase64(String dataBytesBase64) { From 26ac7e5be5a5269fd7c10a639c5ca0b3db761fbe Mon Sep 17 00:00:00 2001 From: kennycud <kennycud@protonmail.com> Date: Thu, 24 Aug 2023 15:47:45 -0700 Subject: [PATCH 24/57] consolidated shared functionality into ACCTTests.java --- .../org/qortal/test/crosschain/ACCTTests.java | 790 ++++++++++++++++++ .../bitcoinv1/BitcoinACCTv1Tests.java | 699 +--------------- .../bitcoinv3/BitcoinACCTv3Tests.java | 775 +---------------- .../digibytev3/DigibyteACCTv3Tests.java | 775 +---------------- .../dogecoinv3/DogecoinACCTv3Tests.java | 775 +---------------- .../litecoinv1/LitecoinACCTv1Tests.java | 774 +---------------- .../litecoinv3/LitecoinACCTv3Tests.java | 777 +---------------- .../piratechainv3/PirateChainACCTv3Tests.java | 777 +---------------- .../ravencoinv3/RavencoinACCTv3Tests.java | 775 +---------------- 9 files changed, 1058 insertions(+), 5859 deletions(-) create mode 100644 src/test/java/org/qortal/test/crosschain/ACCTTests.java 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<ATData> 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<Long, String> 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<Long, String> 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/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<ATData> 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<Long, String> 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<Long, String> 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<ATData> 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<Long, String> 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<ATData> 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<Long, String> 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<ATData> 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<Long, String> 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<ATData> 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<Long, String> 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<ATData> 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<Long, String> 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<ATData> 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<Long, String> 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<ATData> 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<Long, String> 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"); - } - } From 3fbcc50503aa68b08301c1b5620c37a1ce7c33ae Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 25 Aug 2023 11:01:48 +0100 Subject: [PATCH 25/57] Fixed deserialization issues with CreationRequest, added validation, and made small code tweaks for consistency with other endpoints. --- .../qortal/api/model/AtCreationRequest.java | 102 ++++++++++++++++++ .../org/qortal/api/resource/AtResource.java | 55 +++++----- .../data/transaction/CreationRequest.java | 78 -------------- 3 files changed, 129 insertions(+), 106 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/AtCreationRequest.java delete mode 100644 src/main/java/org/qortal/data/transaction/CreationRequest.java 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/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index 80a6e299..13bfec83 100644 --- a/src/main/java/org/qortal/api/resource/AtResource.java +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -9,7 +9,6 @@ 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.util.List; import javax.servlet.http.HttpServletRequest; @@ -28,7 +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.data.transaction.CreationRequest; +import org.qortal.api.model.AtCreationRequest; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -39,10 +38,11 @@ import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.Base58; -import java.util.Base64; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.ObjectMapper; + + @Path("/at") @Tag(name = "Automated Transactions") public class AtResource { @@ -163,21 +163,21 @@ public class AtResource { } @POST - @Path("/createMachineState") + @Path("/create") @Operation( - summary = "Create MachineState bytes from the provided parameters", + summary = "Create base58-encoded AT creation bytes from the provided parameters", requestBody = @RequestBody( required = true, content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema( - implementation = CreationRequest.class + implementation = AtCreationRequest.class ) ) ), responses = { @ApiResponse( - description = "MachineState bytes", + description = "AT creation bytes suitable for use in a DEPLOY_AT transaction", content = @Content( mediaType = MediaType.TEXT_PLAIN, schema = @Schema( @@ -187,27 +187,26 @@ public class AtResource { ) } ) - public String createMachineState(String jsonBody) throws IOException { - ObjectMapper objectMapper = new ObjectMapper(); - CreationRequest request = objectMapper.readValue(jsonBody, CreationRequest.class); - - logger.info("ciyamAtVersion: {}", request.getCiyamAtVersion()); - logger.info("codeBytes: {}", request.getCodeBytes()); - logger.info("codeBytes: {}", request.getNumUserStackPages()); - logger.info("codeBytes: {}", request.getDataBytes()); - logger.info("codeBytes: {}", request.getNumCallStackPages()); - logger.info("codeBytes: {}", request.getMinActivationAmount()); - byte[] creationBytes = MachineState.toCreationBytes( - request.getCiyamAtVersion(), - request.getCodeBytes(), - request.getDataBytes(), - request.getNumCallStackPages(), - request.getNumUserStackPages(), - request.getMinActivationAmount() - ); - return Base58.encode(creationBytes); - + 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( diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java deleted file mode 100644 index 9d50458c..00000000 --- a/src/main/java/org/qortal/data/transaction/CreationRequest.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.qortal.data.transaction; - -import java.util.Base64; -import com.fasterxml.jackson.annotation.JsonProperty; -public class CreationRequest { - - private short ciyamAtVersion; - @JsonProperty("codeBytesBase64") - private String codeBytesBase64; - private String dataBytesBase64; - private short numCallStackPages; - private short numUserStackPages; - private long minActivationAmount; - - // Default constructor for JSON deserialization - public CreationRequest() {} - - // Getters and setters - public short getCiyamAtVersion() { - return ciyamAtVersion; - } - - public void setCiyamAtVersion(short ciyamAtVersion) { - this.ciyamAtVersion = ciyamAtVersion; - } - - public byte[] getCodeBytes() { - if (this.codeBytesBase64 != null) { - return Base64.getDecoder().decode(this.codeBytesBase64); - } - return new byte[0]; - } - - public void setCodeBytesBase64(String codeBytesBase64) { - this.codeBytesBase64 = codeBytesBase64; - } - public String getCodeBytes2() { - return codeBytesBase64; - - } - - - - public byte[] getDataBytes() { - if (this.dataBytesBase64 != null) { - return Base64.getDecoder().decode(this.dataBytesBase64); - } - return new byte[0]; - } - - public void setDataBytesBase64(String dataBytesBase64) { - this.dataBytesBase64 = dataBytesBase64; - } - - 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; - } -} From 760788e82b76ab8f99e33c45c111bca226f48188 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 25 Aug 2023 12:12:32 +0100 Subject: [PATCH 26/57] Updated tools. --- tools/approve-auto-update.sh | 2 +- tools/approve-dev-transaction.sh | 2 +- tools/publish-auto-update-v5.pl | 163 ------------------------------- tools/publish-auto-update.pl | 20 +++- tools/tx.pl | 4 +- 5 files changed, 19 insertions(+), 172 deletions(-) delete mode 100755 tools/publish-auto-update-v5.pl 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 <<TX_END { "timestamp": ${timestamp}, "reference": "${lastref}", - "fee": 0.001, + "fee": 0.01, "txGroupId": 0, "adminPublicKey": "${pubkey}", "pendingSignature": "${sig}", diff --git a/tools/approve-dev-transaction.sh b/tools/approve-dev-transaction.sh index 6b611b59..910e0ae9 100755 --- a/tools/approve-dev-transaction.sh +++ b/tools/approve-dev-transaction.sh @@ -50,7 +50,7 @@ tx_json=$( cat <<TX_END { "timestamp": ${timestamp}, "reference": "${lastref}", - "fee": 0.001, + "fee": 0.01, "txGroupId": 0, "adminPublicKey": "${pubkey}", "pendingSignature": "${sig}", diff --git a/tools/publish-auto-update-v5.pl b/tools/publish-auto-update-v5.pl deleted file mode 100755 index f97fe115..00000000 --- a/tools/publish-auto-update-v5.pl +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env perl - -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"); -} - -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 (<POM>) { - if (m/<artifactId>(\w+)<.artifactId>/o) { - $project = $1; - last; - } -} -close(POM); - -my $apikey = read_file('apikey.txt'); - -# 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 -H "X-API-KEY: ${apikey}" -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 (<POM>) { } 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/tx.pl b/tools/tx.pl index 1cb3dd5b..7cdf444b 100755 --- a/tools/tx.pl +++ b/tools/tx.pl @@ -40,7 +40,7 @@ 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.001; +our $DEFAULT_FEE = 0.01; our %TRANSACTION_TYPES = ( payment => { @@ -92,7 +92,7 @@ our %TRANSACTION_TYPES = ( }, remove_group_admin => { url => 'groups/removeadmin', - required => [qw(groupId txGroupId member)], + required => [qw(groupId txGroupId admin)], key_name => 'ownerPublicKey', }, group_approval => { From ad51073f25a0582c1b7555b470ea83517faa25d6 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 25 Aug 2023 16:11:43 +0100 Subject: [PATCH 27/57] mempowTransactionUpdatesTimestamp set to Fri Sep 01 2023 09:00:00 UTC --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 996ae2c1..9a26c99e 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -29,7 +29,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, - "mempowTransactionUpdatesTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 1693558800000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From a08f10ece3307cb93596970f6f30d64f692a28f3 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 25 Aug 2023 16:13:23 +0100 Subject: [PATCH 28/57] Bump version to 4.3.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5ba103e6..fbcd40a5 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ <modelVersion>4.0.0</modelVersion> <groupId>org.qortal</groupId> <artifactId>qortal</artifactId> - <version>4.2.4</version> + <version>4.3.0</version> <packaging>jar</packaging> <properties> <skipTests>true</skipTests> From eb6a834fd96014f5dcd2b70aca28bc0aff1596af Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 1 Sep 2023 10:47:45 +0100 Subject: [PATCH 29/57] Default minPeerVersion set to 4.3.0 --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index ab1936c7..bdff9506 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -230,7 +230,7 @@ public class Settings { public long recoveryModeTimeout = 9999999999999L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "4.1.2"; + 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 */ From 053d56d01d567280172393bce63a6270323831d6 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 5 Sep 2023 09:40:01 +0200 Subject: [PATCH 30/57] Update hsqldb to 2.7.2 , ciyam at to 1.4.1 --- lib/org/ciyam/AT/1.4.1/AT-1.4.1.jar | Bin 0 -> 161850 bytes lib/org/ciyam/AT/1.4.1/AT-1.4.1.pom | 124 ++++++++++++++++++ lib/org/ciyam/AT/maven-metadata-local.xml | 5 +- pom.xml | 24 ++-- .../hsqldb/HSQLDBDatabaseUpdates.java | 20 +-- 5 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 lib/org/ciyam/AT/1.4.1/AT-1.4.1.jar create mode 100644 lib/org/ciyam/AT/1.4.1/AT-1.4.1.pom diff --git a/lib/org/ciyam/AT/1.4.1/AT-1.4.1.jar b/lib/org/ciyam/AT/1.4.1/AT-1.4.1.jar new file mode 100644 index 0000000000000000000000000000000000000000..05c548c8b59e0ca4254cb43fe5f809fe80a0c83e GIT binary patch literal 161850 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%0DO`@kw<)rB+2Ml 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 literal 0 HcmV?d00001 diff --git a/lib/org/ciyam/AT/1.4.1/AT-1.4.1.pom b/lib/org/ciyam/AT/1.4.1/AT-1.4.1.pom new file mode 100644 index 00000000..16f644b9 --- /dev/null +++ b/lib/org/ciyam/AT/1.4.1/AT-1.4.1.pom @@ -0,0 +1,124 @@ +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <groupId>org.ciyam</groupId> + <artifactId>AT</artifactId> + <version>1.4.1</version> + <packaging>jar</packaging> + + <properties> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + <skipTests>false</skipTests> + + <maven-compiler-plugin.version>3.8.1</maven-compiler-plugin.version> + <maven-source-plugin.version>3.2.0</maven-source-plugin.version> + <maven-javadoc-plugin.version>3.3.1</maven-javadoc-plugin.version> + <maven-surefire-plugin.version>3.0.0-M4</maven-surefire-plugin.version> + <maven-jar-plugin.version>3.2.0</maven-jar-plugin.version> + + <bouncycastle.version>1.64</bouncycastle.version> + </properties> + + <build> + <sourceDirectory>src/main/java</sourceDirectory> + <testSourceDirectory>src/test/java</testSourceDirectory> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>${maven-compiler-plugin.version}</version> + <configuration> + <source>11</source> + <target>11</target> + </configuration> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <version>${maven-surefire-plugin.version}</version> + <configuration> + <skipTests>${skipTests}</skipTests> + </configuration> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-source-plugin</artifactId> + <version>${maven-source-plugin.version}</version> + <executions> + <execution> + <id>attach-sources</id> + <goals> + <goal>jar</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-javadoc-plugin</artifactId> + <version>${maven-javadoc-plugin.version}</version> + <executions> + <execution> + <id>attach-javadoc</id> + <goals> + <goal>jar</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jar-plugin</artifactId> + <version>${maven-jar-plugin.version}</version> + <executions> + <execution> + <goals> + <goal>test-jar</goal> + </goals> + </execution> + </executions> + </plugin> + </plugins> + </build> + + <dependencies> + <dependency> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>${maven-compiler-plugin.version}</version> + </dependency> + <dependency> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <version>${maven-surefire-plugin.version}</version> + </dependency> + <dependency> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-source-plugin</artifactId> + <version>${maven-source-plugin.version}</version> + </dependency> + <dependency> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-javadoc-plugin</artifactId> + <version>${maven-javadoc-plugin.version}</version> + </dependency> + <dependency> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jar-plugin</artifactId> + <version>${maven-jar-plugin.version}</version> + </dependency> + <dependency> + <groupId>org.bouncycastle</groupId> + <artifactId>bcprov-jdk15on</artifactId> + <version>${bouncycastle.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + <version>4.13</version> + <scope>test</scope> + </dependency> + </dependencies> +</project> diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index 063c735d..d8f3dd34 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,7 +3,7 @@ <groupId>org.ciyam</groupId> <artifactId>AT</artifactId> <versioning> - <release>1.4.0</release> + <release>1.4.1</release> <versions> <version>1.3.4</version> <version>1.3.5</version> @@ -11,7 +11,8 @@ <version>1.3.7</version> <version>1.3.8</version> <version>1.4.0</version> + <version>1.4.1</version> </versions> - <lastUpdated>20221105114346</lastUpdated> + <lastUpdated>20230821074325</lastUpdated> </versioning> </metadata> diff --git a/pom.xml b/pom.xml index fbcd40a5..0d286d8d 100644 --- a/pom.xml +++ b/pom.xml @@ -11,18 +11,18 @@ <bitcoinj.version>0.15.10</bitcoinj.version> <bouncycastle.version>1.69</bouncycastle.version> <build.timestamp>${maven.build.timestamp}</build.timestamp> - <ciyam-at.version>1.4.0</ciyam-at.version> - <commons-net.version>3.6</commons-net.version> - <commons-text.version>1.8</commons-text.version> - <commons-io.version>2.6</commons-io.version> - <commons-compress.version>1.21</commons-compress.version> - <commons-lang3.version>3.12.0</commons-lang3.version> + <ciyam-at.version>1.4.1</ciyam-at.version> + <commons-net.version>3.9.0</commons-net.version> + <commons-text.version>1.10.0</commons-text.version> + <commons-io.version>2.13.0</commons-io.version> + <commons-compress.version>1.23.0</commons-compress.version> + <commons-lang3.version>3.13.0</commons-lang3.version> <xz.version>1.9</xz.version> <dagger.version>1.2.2</dagger.version> - <guava.version>28.1-jre</guava.version> - <hsqldb.version>2.5.1</hsqldb.version> + <guava.version>32.1.2-jre</guava.version> + <hsqldb.version>2.7.2</hsqldb.version> <homoglyph.version>1.2.1</homoglyph.version> - <icu4j.version>70.1</icu4j.version> + <icu4j.version>73.2</icu4j.version> <upnp.version>1.1</upnp.version> <jersey.version>2.29.1</jersey.version> <jetty.version>9.4.29.v20200521</jetty.version> @@ -33,9 +33,9 @@ <swagger-ui.version>3.23.8</swagger-ui.version> <package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> <jsoup.version>1.13.1</jsoup.version> - <java-diff-utils.version>4.10</java-diff-utils.version> - <grpc.version>1.45.1</grpc.version> - <protobuf.version>3.19.4</protobuf.version> + <java-diff-utils.version>4.12</java-diff-utils.version> + <grpc.version>1.57.2</grpc.version> + <protobuf.version>3.24.2</protobuf.version> <simplemagic.version>1.17</simplemagic.version> </properties> <build> diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index cd2b30fa..c2e9cd62 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -454,40 +454,40 @@ public class HSQLDBDatabaseUpdates { case 12: // Groups - stmt.execute("CREATE TABLE Groups (group_id GroupID, owner QortalAddress NOT NULL, group_name GroupName NOT NULL, " + stmt.execute("CREATE TABLE `Groups` (group_id GroupID, owner QortalAddress NOT NULL, group_name GroupName NOT NULL, " + "created_when EpochMillis NOT NULL, updated_when EpochMillis, is_open BOOLEAN NOT NULL, " + "approval_threshold TINYINT NOT NULL, min_block_delay INTEGER NOT NULL, max_block_delay INTEGER NOT NULL, " + "reference Signature, creation_group_id GroupID, reduced_group_name GroupName NOT NULL, " + "description GenericDescription NOT NULL, PRIMARY KEY (group_id))"); // For finding groups by name - stmt.execute("CREATE INDEX GroupNameIndex on Groups (group_name)"); + stmt.execute("CREATE INDEX GroupNameIndex on `Groups` (group_name)"); // For finding groups by reduced name - stmt.execute("CREATE INDEX GroupReducedNameIndex on Groups (reduced_group_name)"); + stmt.execute("CREATE INDEX GroupReducedNameIndex on `Groups` (reduced_group_name)"); // For finding groups by owner - stmt.execute("CREATE INDEX GroupOwnerIndex ON Groups (owner)"); + stmt.execute("CREATE INDEX GroupOwnerIndex ON `Groups` (owner)"); // We need a corresponding trigger to make sure new group_id values are assigned sequentially starting from 1 - stmt.execute("CREATE TRIGGER Group_ID_Trigger BEFORE INSERT ON Groups " + stmt.execute("CREATE TRIGGER Group_ID_Trigger BEFORE INSERT ON `Groups` " + "REFERENCING NEW ROW AS new_row FOR EACH ROW WHEN (new_row.group_id IS NULL) " - + "SET new_row.group_id = (SELECT IFNULL(MAX(group_id) + 1, 1) FROM Groups)"); + + "SET new_row.group_id = (SELECT IFNULL(MAX(group_id) + 1, 1) FROM `Groups`)"); // Admins stmt.execute("CREATE TABLE GroupAdmins (group_id GroupID, admin QortalAddress, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, admin), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, admin), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); // For finding groups by admin address stmt.execute("CREATE INDEX GroupAdminIndex ON GroupAdmins (admin)"); // Members stmt.execute("CREATE TABLE GroupMembers (group_id GroupID, address QortalAddress, " + "joined_when EpochMillis NOT NULL, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, address), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, address), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); // For finding groups by member address stmt.execute("CREATE INDEX GroupMemberIndex ON GroupMembers (address)"); // Invites stmt.execute("CREATE TABLE GroupInvites (group_id GroupID, inviter QortalAddress, invitee QortalAddress, " + "expires_when EpochMillis, reference Signature, " - + "PRIMARY KEY (group_id, invitee), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, invitee), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); // For finding invites sent by inviter stmt.execute("CREATE INDEX GroupInviteInviterIndex ON GroupInvites (inviter)"); // For finding invites by group @@ -503,7 +503,7 @@ public class HSQLDBDatabaseUpdates { // NULL expires_when means does not expire! stmt.execute("CREATE TABLE GroupBans (group_id GroupID, offender QortalAddress, admin QortalAddress NOT NULL, " + "banned_when EpochMillis NOT NULL, reason GenericDescription NOT NULL, expires_when EpochMillis, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, offender), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, offender), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); // For expiry maintenance stmt.execute("CREATE INDEX GroupBanExpiryIndex ON GroupBans (expires_when)"); break; From b92c7cc86651faaec21f020e712383facc05870f Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 5 Sep 2023 11:35:53 +0200 Subject: [PATCH 31/57] Update dependencies and ntp servers --- pom.xml | 18 ++--- .../java/org/qortal/settings/Settings.java | 73 +++++++++++-------- 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/pom.xml b/pom.xml index 0d286d8d..32e9343e 100644 --- a/pom.xml +++ b/pom.xml @@ -51,14 +51,14 @@ <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>versions-maven-plugin</artifactId> - <version>2.5</version> + <version>2.16.0</version> <configuration> <generateBackupPoms>false</generateBackupPoms> </configuration> </plugin> <plugin> <artifactId>maven-compiler-plugin</artifactId> - <version>3.8.0</version> + <version>3.11.0</version> <configuration> <release>11</release> </configuration> @@ -232,7 +232,7 @@ <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> - <version>3.0.0</version> + <version>3.4.0</version> <executions> <execution> <phase>generate-sources</phase> @@ -250,7 +250,7 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> - <version>3.2.0</version> + <version>3.3.0</version> <configuration> <archive> <manifest> @@ -268,7 +268,7 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> - <version>2.4.3</version> + <version>3.5.0</version> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> <artifactSet> @@ -318,7 +318,7 @@ <plugin> <groupId>io.github.zlika</groupId> <artifactId>reproducible-build-maven-plugin</artifactId> - <version>0.11</version> + <version>0.16</version> <executions> <execution> <phase>package</phase> @@ -335,7 +335,7 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> - <version>2.22.2</version> + <version>3.1.2</version> <configuration> <skipTests>${skipTests}</skipTests> </configuration> @@ -360,7 +360,7 @@ maven-dependency-plugin </artifactId> <versionRange> - [2.8,) + [3.6.0,) </versionRange> <goals> <goal>unpack</goal> @@ -413,7 +413,7 @@ <dependency> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> - <version>3.0.0</version> + <version>3.4.0</version> <scope>provided</scope><!-- needed for build, not for runtime --> </dependency> <!-- https://mvnrepository.com/artifact/com.github.bohnman/package-info-maven-plugin --> diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index bdff9506..f9f4eb0b 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -47,8 +47,8 @@ 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 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"; @@ -110,10 +110,9 @@ public class Settings { private boolean gatewayLoggingEnabled = false; private boolean gatewayLoopbackEnabled = false; - // Developer Proxy + // Developer Proxy private Integer devProxyPort; - private boolean devProxyLoggingEnabled = false; - + private boolean devProxyLoggingEnabled = false; // Specific to this node private boolean wipeUnconfirmedOnStart = false; @@ -186,7 +185,6 @@ public class Settings { * This has a significant effect on execution time. */ private int blockPruneBatchSize = 10000; // blocks - /** Whether we should archive old data to reduce the database size */ private boolean archiveEnabled = true; /** How often to attempt archiving (ms). */ @@ -194,15 +192,12 @@ public class Settings { /** Serialization version to use when building an archive */ private int defaultArchiveVersion = 2; - /** Whether to automatically bootstrap instead of syncing from genesis */ private boolean bootstrap = true; - /** Registered names integrity check */ private boolean namesIntegrityCheckEnabled = false; - // Peer-to-peer related private boolean isTestNet = false; /** Single node testnet mode */ @@ -289,10 +284,10 @@ public class Settings { // Bootstrap sources private String[] bootstrapHosts = new String[] { - "http://bootstrap.qortal.org", - "http://bootstrap2.qortal.org", - "http://bootstrap3.qortal.org", - "http://bootstrap.qortal.online" + "http://bootstrap.qortal.org", + "http://bootstrap2.qortal.org", + "http://bootstrap3.qortal.org", + "http://bootstrap.qortal.online" }; // Auto-update sources @@ -311,17 +306,35 @@ public class Settings { "1.pool.ntp.org", "2.pool.ntp.org", "3.pool.ntp.org", - "cn.pool.ntp.org", - "0.cn.pool.ntp.org", - "1.cn.pool.ntp.org", - "2.cn.pool.ntp.org", - "3.cn.pool.ntp.org" + "asia.pool.ntp.org", + "0.asia.pool.ntp.org", + "1.asia.pool.ntp.org", + "2.asia.pool.ntp.org", + "3.asia.pool.ntp.org", + "europe.pool.ntp.org", + "0.europe.pool.ntp.org", + "1.europe.pool.ntp.org", + "2.europe.pool.ntp.org", + "3.europe.pool.ntp.org", + "north-america.pool.ntp.org", + "0.north-america.pool.ntp.org", + "1.north-america.pool.ntp.org", + "2.north-america.pool.ntp.org", + "3.north-america.pool.ntp.org", + "oceania.pool.ntp.org", + "0.oceania.pool.ntp.org", + "1.oceania.pool.ntp.org", + "2.oceania.pool.ntp.org", + "3.oceania.pool.ntp.org", + "south-america.pool.ntp.org", + "0.south-america.pool.ntp.org", + "1.south-america.pool.ntp.org", + "2.south-america.pool.ntp.org", + "3.south-america.pool.ntp.org" }; /** Additional offset added to values returned by NTP.getTime() */ private Long testNtpOffset = null; - - /* Foreign chains */ /** The number of consecutive empty addresses required before treating a wallet's transaction set as complete */ @@ -330,8 +343,6 @@ public class Settings { /** How many wallet keys to generate when using bitcoinj as the blockchain interface (e.g. when sending coins) */ private int bitcoinjLookaheadSize = 50; - - // Data storage (QDN) /** Data storage enabled/disabled*/ @@ -396,7 +407,6 @@ public class Settings { } } - // Constructors private Settings() { @@ -660,17 +670,16 @@ public class Settings { } - public int getDevProxyPort() { - if (this.devProxyPort != null) - return this.devProxyPort; + 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; - } + return this.isTestNet ? TESTNET_DEV_PROXY_PORT : MAINNET_DEV_PROXY_PORT; + } + public boolean isDevProxyLoggingEnabled() { + return this.devProxyLoggingEnabled; + } public boolean getWipeUnconfirmedOnStart() { return this.wipeUnconfirmedOnStart; From 43921e6ab869d7d04cb69efa0a6a3d051b2bb870 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 5 Sep 2023 13:44:54 +0200 Subject: [PATCH 32/57] Update jetty server --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 32e9343e..0cbb83cf 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ <icu4j.version>73.2</icu4j.version> <upnp.version>1.1</upnp.version> <jersey.version>2.29.1</jersey.version> - <jetty.version>9.4.29.v20200521</jetty.version> + <jetty.version>9.4.52.v20230823</jetty.version> <log4j.version>2.17.1</log4j.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <slf4j.version>1.7.12</slf4j.version> From 6be3897fdb5444847db2750c8d2f2685007da4a9 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 5 Sep 2023 15:06:46 +0200 Subject: [PATCH 33/57] Update logging --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 0cbb83cf..5205c1b3 100644 --- a/pom.xml +++ b/pom.xml @@ -26,9 +26,9 @@ <upnp.version>1.1</upnp.version> <jersey.version>2.29.1</jersey.version> <jetty.version>9.4.52.v20230823</jetty.version> - <log4j.version>2.17.1</log4j.version> + <log4j.version>2.20.0</log4j.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> - <slf4j.version>1.7.12</slf4j.version> + <slf4j.version>2.0.9</slf4j.version> <swagger-api.version>2.0.9</swagger-api.version> <swagger-ui.version>3.23.8</swagger-ui.version> <package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> From 12dbff79c9fff49aba5e4dd0717b79fc7546eda2 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 5 Sep 2023 15:20:21 +0200 Subject: [PATCH 34/57] Update logging properties --- log4j2.properties | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/log4j2.properties b/log4j2.properties index 44e1b1e3..54f295c1 100644 --- a/log4j2.properties +++ b/log4j2.properties @@ -1,7 +1,7 @@ rootLogger.level = info # On Windows, uncomment next line to set dirname: # property.dirname = ${sys:user.home}\\AppData\\Local\\qortal\\ -property.filename = ${sys:log4j2.filenameTemplate:-log.txt} +# property.filename = ${sys:log4j2.filenameTemplate:-log.txt} rootLogger.appenderRef.console.ref = stdout rootLogger.appenderRef.rolling.ref = FILE @@ -59,11 +59,14 @@ appender.console.filter.threshold.level = error appender.rolling.type = RollingFile appender.rolling.name = FILE +appender.rolling.fileName = qortal.log +appender.rolling.filePattern = qortal.%d{dd-MMM}.log.gz appender.rolling.layout.type = PatternLayout appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n -appender.rolling.filePattern = ./${filename}.%i appender.rolling.policy.type = SizeBasedTriggeringPolicy -appender.rolling.policy.size = 4MB +appender.rolling.policy.size = 10MB +appender.rolling.strategy.type = DefaultRolloverStrategy +appender.rolling.strategy.max = 7 # Set the immediate flush to true (default) # appender.rolling.immediateFlush = true # Set the append to true (default), should not overwrite From c0ed4022a5256b9d47eb879c8f73ed6e7d49418a Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 5 Sep 2023 15:27:33 +0200 Subject: [PATCH 35/57] Update log4j2.properties --- WindowsInstaller/Install Files/log4j2.properties | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Install Files/log4j2.properties b/WindowsInstaller/Install Files/log4j2.properties index 44e1b1e3..54f295c1 100755 --- a/WindowsInstaller/Install Files/log4j2.properties +++ b/WindowsInstaller/Install Files/log4j2.properties @@ -1,7 +1,7 @@ rootLogger.level = info # On Windows, uncomment next line to set dirname: # property.dirname = ${sys:user.home}\\AppData\\Local\\qortal\\ -property.filename = ${sys:log4j2.filenameTemplate:-log.txt} +# property.filename = ${sys:log4j2.filenameTemplate:-log.txt} rootLogger.appenderRef.console.ref = stdout rootLogger.appenderRef.rolling.ref = FILE @@ -59,11 +59,14 @@ appender.console.filter.threshold.level = error appender.rolling.type = RollingFile appender.rolling.name = FILE +appender.rolling.fileName = qortal.log +appender.rolling.filePattern = qortal.%d{dd-MMM}.log.gz appender.rolling.layout.type = PatternLayout appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n -appender.rolling.filePattern = ./${filename}.%i appender.rolling.policy.type = SizeBasedTriggeringPolicy -appender.rolling.policy.size = 4MB +appender.rolling.policy.size = 10MB +appender.rolling.strategy.type = DefaultRolloverStrategy +appender.rolling.strategy.max = 7 # Set the immediate flush to true (default) # appender.rolling.immediateFlush = true # Set the append to true (default), should not overwrite From bd055780358a347601f7b76f06a512f60fa0c755 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 5 Sep 2023 18:31:32 +0200 Subject: [PATCH 36/57] Reverse need latest 1.7.x --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5205c1b3..bdc4f1c6 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ <jetty.version>9.4.52.v20230823</jetty.version> <log4j.version>2.20.0</log4j.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> - <slf4j.version>2.0.9</slf4j.version> + <slf4j.version>1.7.36</slf4j.version> <swagger-api.version>2.0.9</swagger-api.version> <swagger-ui.version>3.23.8</swagger-ui.version> <package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> From 94e9f86245157310f868d3ed7368a50535cc8796 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Wed, 6 Sep 2023 09:11:41 +0200 Subject: [PATCH 37/57] Version 3.8.0 get faster the NTP offset --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index bdc4f1c6..30448cab 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ <bouncycastle.version>1.69</bouncycastle.version> <build.timestamp>${maven.build.timestamp}</build.timestamp> <ciyam-at.version>1.4.1</ciyam-at.version> - <commons-net.version>3.9.0</commons-net.version> + <commons-net.version>3.8.0</commons-net.version> <commons-text.version>1.10.0</commons-text.version> <commons-io.version>2.13.0</commons-io.version> <commons-compress.version>1.23.0</commons-compress.version> From 1a9087984a8425d63bf7db320470a592909bce3c Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:22:13 +0200 Subject: [PATCH 38/57] Swagger updates --- pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 30448cab..8cbb7b58 100644 --- a/pom.xml +++ b/pom.xml @@ -29,8 +29,8 @@ <log4j.version>2.20.0</log4j.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <slf4j.version>1.7.36</slf4j.version> - <swagger-api.version>2.0.9</swagger-api.version> - <swagger-ui.version>3.23.8</swagger-ui.version> + <swagger-api.version>2.0.10</swagger-api.version> + <swagger-ui.version>3.23.11</swagger-ui.version> <package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> <jsoup.version>1.13.1</jsoup.version> <java-diff-utils.version>4.12</java-diff-utils.version> @@ -89,7 +89,7 @@ <plugin> <groupId>pl.project13.maven</groupId> <artifactId>git-commit-id-plugin</artifactId> - <version>4.0.0</version> + <version>4.9.10</version> <executions> <execution> <id>get-the-git-infos</id> From a7ca306d1b7a9d36690fa897c0251a249790b41d Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Thu, 7 Sep 2023 16:54:09 +0200 Subject: [PATCH 39/57] Update json dependency --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 8cbb7b58..85e086ae 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ <swagger-api.version>2.0.10</swagger-api.version> <swagger-ui.version>3.23.11</swagger-ui.version> <package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> - <jsoup.version>1.13.1</jsoup.version> + <jsoup.version>1.16.1</jsoup.version> <java-diff-utils.version>4.12</java-diff-utils.version> <grpc.version>1.57.2</grpc.version> <protobuf.version>3.24.2</protobuf.version> @@ -462,7 +462,7 @@ <dependency> <groupId>org.json</groupId> <artifactId>json</artifactId> - <version>20210307</version> + <version>20230618</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> From d453e80c6b9bce924531e6b2abff1ba0376f34fb Mon Sep 17 00:00:00 2001 From: QuickMythril <quickmythril@protonmail.com> Date: Fri, 8 Sep 2023 03:30:06 -0400 Subject: [PATCH 40/57] Update fee in qdn script --- tools/qdn | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/qdn b/tools/qdn index adbe861d..b8f58141 100755 --- a/tools/qdn +++ b/tools/qdn @@ -131,12 +131,12 @@ if [[ "${method}" == "POST" ]]; then fi if [ -n "$fee" ]; then - if [[ "$fee" == "1" || "$fee" == ".001" ]]; then - fee="100000" + if [[ "$fee" == "1" || "$fee" == ".01" ]]; then + fee="1000000" elif [ -z "$fee" ]; then fee="" else - echo "Error: Invalid fee value. Expected '1', '.001' or no input." + echo "Error: Invalid fee value. Expected '1', '.01' or no input." exit 1 fi final_fee="${fee}" From 9959985a13aa0c9cbdc8d7bae5fa5cd2a0b5c1bc Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Sat, 9 Sep 2023 10:56:17 +0200 Subject: [PATCH 41/57] Update grpc --- pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 85e086ae..bea65026 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ <package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> <jsoup.version>1.16.1</jsoup.version> <java-diff-utils.version>4.12</java-diff-utils.version> - <grpc.version>1.57.2</grpc.version> + <grpc.version>1.58.0</grpc.version> <protobuf.version>3.24.2</protobuf.version> <simplemagic.version>1.17</simplemagic.version> </properties> @@ -168,7 +168,7 @@ <replacements> <replacement> <token>^(#.*$[\n\r]*)</token> - <value></value> + <value/> </replacement> </replacements> </configuration> @@ -367,7 +367,7 @@ </goals> </pluginExecutionFilter> <action> - <execute></execute> + <execute/> </action> </pluginExecution> <pluginExecution> @@ -386,7 +386,7 @@ </goals> </pluginExecutionFilter> <action> - <execute></execute> + <execute/> </action> </pluginExecution> </pluginExecutions> From 1fbb1659a33b56c6134b239b1cd3582c356e69bf Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Wed, 13 Sep 2023 10:43:05 +0200 Subject: [PATCH 42/57] Update dependencies --- pom.xml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index bea65026..29b7ac58 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ <commons-net.version>3.8.0</commons-net.version> <commons-text.version>1.10.0</commons-text.version> <commons-io.version>2.13.0</commons-io.version> - <commons-compress.version>1.23.0</commons-compress.version> + <commons-compress.version>1.24.0</commons-compress.version> <commons-lang3.version>3.13.0</commons-lang3.version> <xz.version>1.9</xz.version> <dagger.version>1.2.2</dagger.version> @@ -35,7 +35,7 @@ <jsoup.version>1.16.1</jsoup.version> <java-diff-utils.version>4.12</java-diff-utils.version> <grpc.version>1.58.0</grpc.version> - <protobuf.version>3.24.2</protobuf.version> + <protobuf.version>3.24.3</protobuf.version> <simplemagic.version>1.17</simplemagic.version> </properties> <build> @@ -164,7 +164,9 @@ <configuration> <file>${project.build.outputDirectory}/git.properties</file> <regex>true</regex> - <regexFlags><regexFlag>MULTILINE</regexFlag></regexFlags> + <regexFlags> + <regexFlag>MULTILINE</regexFlag> + </regexFlags> <replacements> <replacement> <token>^(#.*$[\n\r]*)</token> @@ -414,14 +416,16 @@ <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> <version>3.4.0</version> - <scope>provided</scope><!-- needed for build, not for runtime --> + <scope>provided</scope> + <!-- needed for build, not for runtime --> </dependency> <!-- https://mvnrepository.com/artifact/com.github.bohnman/package-info-maven-plugin --> <dependency> <groupId>com.github.bohnman</groupId> <artifactId>package-info-maven-plugin</artifactId> <version>${package-info-maven-plugin.version}</version> - <scope>provided</scope><!-- needed for build, not for runtime --> + <scope>provided</scope> + <!-- needed for build, not for runtime --> </dependency> <!-- HSQLDB for repository --> <dependency> @@ -638,7 +642,8 @@ <artifactId>jersey-hk2</artifactId> <version>${jersey.version}</version> <exclusions> - <exclusion><!-- exclude javax.inject-1.jar because other jersey modules include javax.inject v2+ --> + <exclusion> + <!-- exclude javax.inject-1.jar because other jersey modules include javax.inject v2+ --> <groupId>javax.inject</groupId> <artifactId>javax.inject</artifactId> </exclusion> @@ -665,7 +670,8 @@ <artifactId>swagger-jaxrs2-servlet-initializer</artifactId> <version>${swagger-api.version}</version> <exclusions> - <exclusion><!-- excluded because included in swagger-jaxrs2-servlet-initializer --> + <exclusion> + <!-- excluded because included in swagger-jaxrs2-servlet-initializer --> <groupId>io.swagger.core.v3</groupId> <artifactId>swagger-integration</artifactId> </exclusion> From cae3fdcb064ef06aa8db80b822300a5feed0c398 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Fri, 22 Sep 2023 11:12:50 +0200 Subject: [PATCH 43/57] Update ElectrumX Servers --- .../java/org/qortal/crosschain/Bitcoin.java | 173 +++++++++--------- .../java/org/qortal/crosschain/Digibyte.java | 24 ++- .../java/org/qortal/crosschain/Dogecoin.java | 25 +-- .../java/org/qortal/crosschain/Litecoin.java | 45 ++--- .../org/qortal/crosschain/PirateChain.java | 18 +- .../java/org/qortal/crosschain/Ravencoin.java | 31 ++-- 6 files changed, 160 insertions(+), 156 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java index b65bac8e..7925dd4e 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoin.java +++ b/src/main/java/org/qortal/crosschain/Bitcoin.java @@ -44,89 +44,78 @@ public class Bitcoin extends Bitcoiny { @Override public Collection<ElectrumX.Server> getServers() { return Arrays.asList( - // Servers chosen on NO BASIS WHATSOEVER from various sources! - // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=btc - //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), - //CLOSED new Server("prospero.bitsrc.net", Server.ConnectionType.SSL, 50002), - //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.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("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("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)); + // Servers chosen on NO BASIS WHATSOEVER from various sources! + // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=btc + 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.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("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("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) + ); } @Override @@ -152,12 +141,13 @@ public class Bitcoin extends Bitcoiny { @Override public Collection<ElectrumX.Server> getServers() { return Arrays.asList( - new Server("tn.not.fyi", Server.ConnectionType.SSL, 55002), - new Server("electrumx-test.1209k.com", Server.ConnectionType.SSL, 50002), - new Server("testnet.qtornado.com", Server.ConnectionType.SSL, 51002), - new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001), - new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002), - new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012)); + new Server("tn.not.fyi", Server.ConnectionType.SSL, 55002), + new Server("electrumx-test.1209k.com", Server.ConnectionType.SSL, 50002), + new Server("testnet.qtornado.com", Server.ConnectionType.SSL, 51002), + new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001), + new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002), + new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012) + ); } @Override @@ -179,8 +169,9 @@ public class Bitcoin extends Bitcoiny { @Override public Collection<ElectrumX.Server> getServers() { return Arrays.asList( - new Server("localhost", Server.ConnectionType.TCP, 50001), - new Server("localhost", Server.ConnectionType.SSL, 50002)); + new Server("localhost", Server.ConnectionType.TCP, 50001), + new Server("localhost", Server.ConnectionType.SSL, 50002) + ); } @Override diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java index c5d96383..8e316173 100644 --- a/src/main/java/org/qortal/crosschain/Digibyte.java +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -43,14 +43,17 @@ public class Digibyte extends Bitcoiny { @Override public Collection<Server> getServers() { 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)); + // 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("electrum1-dgb.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum2-dgb.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum3-dgb.qortal.online", Server.ConnectionType.SSL, 40002), + new Server("electrum4-dgb.qortal.online", Server.ConnectionType.SSL, 40002), + new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 20059), + new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20059), + new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20059) + ); } @Override @@ -94,8 +97,9 @@ public class Digibyte extends Bitcoiny { @Override public Collection<Server> getServers() { return Arrays.asList( - new Server("localhost", ConnectionType.TCP, 50001), - new Server("localhost", ConnectionType.SSL, 50002)); + new Server("localhost", Server.ConnectionType.TCP, 50001), + new Server("localhost", Server.ConnectionType.SSL, 50002) + ); } @Override diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index 99f557a5..93941c41 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -4,7 +4,6 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.core.Context; import org.bitcoinj.core.NetworkParameters; import org.libdohj.params.DogecoinMainNetParams; -//import org.libdohj.params.DogecoinRegTestParams; import org.libdohj.params.DogecoinTestNet3Params; import org.qortal.crosschain.ElectrumX.Server; import org.qortal.crosschain.ElectrumX.Server.ConnectionType; @@ -44,14 +43,17 @@ public class Dogecoin extends Bitcoiny { @Override public Collection<Server> 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)); + // 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("electrum1-doge.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum2-doge.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum3-doge.qortal.online", Server.ConnectionType.SSL, 30002), + new Server("electrum4-doge.qortal.online", Server.ConnectionType.SSL, 30002), + new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 20060), + new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20060), + new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20060) + ); } @Override @@ -95,8 +97,9 @@ public class Dogecoin extends Bitcoiny { @Override public Collection<Server> getServers() { return Arrays.asList( - new Server("localhost", ConnectionType.TCP, 50001), - new Server("localhost", ConnectionType.SSL, 50002)); + new Server("localhost", Server.ConnectionType.TCP, 50001), + new Server("localhost", Server.ConnectionType.SSL, 50002) + ); } @Override diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 1dd9037a..22825c74 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -43,22 +43,21 @@ public class Litecoin extends Bitcoiny { @Override public Collection<ElectrumX.Server> getServers() { 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.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.rentonrisk.com", Server.ConnectionType.SSL, 50002)); + // Servers chosen on NO BASIS WHATSOEVER from various sources! + // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=ltc + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 50002), + new Server("electrum1-ltc.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum2-ltc.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum3-ltc.qortal.online", Server.ConnectionType.SSL, 20002), + new Server("electrum4-ltc.qortal.online", Server.ConnectionType.SSL, 20002), + new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443), + new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002), + new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), + 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.rentonrisk.com", Server.ConnectionType.SSL, 50002) + ); } @Override @@ -81,10 +80,11 @@ public class Litecoin extends Bitcoiny { @Override public Collection<ElectrumX.Server> getServers() { return Arrays.asList( - new Server("electrum-ltc.bysh.me", Server.ConnectionType.TCP, 51001), - new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 51002), - new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 51001), - new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 51002)); + new Server("electrum-ltc.bysh.me", Server.ConnectionType.TCP, 51001), + new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 51002), + new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 51001), + new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 51002) + ); } @Override @@ -106,8 +106,9 @@ public class Litecoin extends Bitcoiny { @Override public Collection<ElectrumX.Server> getServers() { return Arrays.asList( - new Server("localhost", Server.ConnectionType.TCP, 50001), - new Server("localhost", Server.ConnectionType.SSL, 50002)); + new Server("localhost", Server.ConnectionType.TCP, 50001), + new Server("localhost", Server.ConnectionType.SSL, 50002) + ); } @Override diff --git a/src/main/java/org/qortal/crosschain/PirateChain.java b/src/main/java/org/qortal/crosschain/PirateChain.java index a1d31a4e..4881c8bb 100644 --- a/src/main/java/org/qortal/crosschain/PirateChain.java +++ b/src/main/java/org/qortal/crosschain/PirateChain.java @@ -56,11 +56,14 @@ public class PirateChain extends Bitcoiny { @Override public Collection<Server> getServers() { return Arrays.asList( - // Servers chosen on NO BASIS WHATSOEVER from various sources! - 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)); + // Servers chosen on NO BASIS WHATSOEVER from various sources! + new Server("wallet-arrr1.qortal.online", Server.ConnectionType.SSL, 443), + new Server("wallet-arrr2.qortal.online", Server.ConnectionType.SSL, 443), + new Server("wallet-arrr3.qortal.online", Server.ConnectionType.SSL, 443), + new Server("wallet-arrr4.qortal.online", Server.ConnectionType.SSL, 443), + new Server("wallet-arrr5.qortal.online", Server.ConnectionType.SSL, 443), + new Server("lightd.pirate.black", Server.ConnectionType.SSL, 443) + ); } @Override @@ -104,8 +107,9 @@ public class PirateChain extends Bitcoiny { @Override public Collection<Server> getServers() { return Arrays.asList( - new Server("localhost", ConnectionType.TCP, 9067), - new Server("localhost", ConnectionType.SSL, 443)); + new Server("localhost", Server.ConnectionType.TCP, 9067), + new Server("localhost", Server.ConnectionType.SSL, 443) + ); } @Override diff --git a/src/main/java/org/qortal/crosschain/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java index 6030fa50..51b65f68 100644 --- a/src/main/java/org/qortal/crosschain/Ravencoin.java +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -43,19 +43,19 @@ public class Ravencoin extends Bitcoiny { @Override public Collection<Server> getServers() { return Arrays.asList( - // Servers chosen on NO BASIS WHATSOEVER from various sources! - // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=rvn - //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("rvn-dashboard.com", ConnectionType.SSL, 50002), - new Server("rvn4lyfe.com", ConnectionType.SSL, 50002)); + // Servers chosen on NO BASIS WHATSOEVER from various sources! + // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=rvn + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 56002), + new Server("electrum1-rvn.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum2-rvn.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum3-rvn.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum4-rvn.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 20051), + new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20051), + new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20051), + new Server("rvn-dashboard.com", Server.ConnectionType.SSL, 50002), + new Server("rvn4lyfe.com", Server.ConnectionType.SSL, 50002) + ); } @Override @@ -99,8 +99,9 @@ public class Ravencoin extends Bitcoiny { @Override public Collection<Server> getServers() { return Arrays.asList( - new Server("localhost", ConnectionType.TCP, 50001), - new Server("localhost", ConnectionType.SSL, 50002)); + new Server("localhost", Server.ConnectionType.TCP, 50001), + new Server("localhost", Server.ConnectionType.SSL, 50002) + ); } @Override From 9454031b488fbe451f0aa2dfbc80ce1ce08716c1 Mon Sep 17 00:00:00 2001 From: CalDescent <caldescent@protonmail.com> Date: Fri, 22 Sep 2023 13:06:03 +0100 Subject: [PATCH 44/57] Added support for thread limits. Default thread limits per message type can be specified in Settings.setAdditionalDefaults(), e.g maxThreadsPerMessageType.add(new ThreadLimit("GET_ARBITRARY_DATA_FILE", 5)); These can also be overridden on a per-node basis in settings.json, e.g "maxThreadsPerMessageType": [ { "messageType": "GET_ARBITRARY_DATA_FILE", "limit": 3 }, { "messageType": "GET_ARBITRARY_DATA_FILE_LIST", "limit": 3 } ] settings.json values take priority, but any message types that aren't specified in settings.json will still be included from the Settings.java defaults. This allows single message types to be overridden in settings.json without removing the limits for all of the other message types. Any messages that arrive are discarded if the node is already at the thread limit for that message type. Warnings are now shown in the logs if the total number of active threads reaches 90% of the allocated thread pool size. Additionally, it can warn per message type by specifying a per-message-type warning threshold in settings.json, e.g "threadCountPerMessageTypeWarningThreshold": 20 The above setting would warn in the logs if a single message type was consuming more than 20 threads at once, therefore making it a candidate to be limited in maxThreadsPerMessageType. Initial values of maxThreadsPerMessageType are guesses and may need modifying based on real world results. Limiting threads may impact functionality, so this should be carefully tested. Also be aware that the thread tracking may reduce network performance slightly, so be sure to test thoroughly on slower hardware. --- src/main/java/org/qortal/network/Network.java | 67 +++++++++++++- .../java/org/qortal/settings/Settings.java | 88 +++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index a3528a66..b42ab450 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -8,7 +8,6 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.qortal.block.BlockChain; import org.qortal.controller.Controller; import org.qortal.controller.arbitrary.ArbitraryDataFileListManager; -import org.qortal.controller.arbitrary.ArbitraryDataManager; import org.qortal.crypto.Crypto; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; @@ -122,6 +121,22 @@ public class Network { private List<Peer> immutableOutboundHandshakedPeers = Collections.emptyList(); // always rebuilt from mutable, synced list above + /** + * Count threads per message type in order to enforce limits + */ + private final Map<MessageType, Integer> threadsPerMessageType = Collections.synchronizedMap(new HashMap<>()); + + /** + * Keep track of total thread count, to warn when the thread pool is getting low + */ + private int totalThreadCount = 0; + + /** + * Thresholds at which to warn about the number of active threads + */ + private final int threadCountWarningThreshold = (int) (Settings.getInstance().getMaxNetworkThreadPoolSize() * 0.9f); + private final Integer threadCountPerMessageTypeWarningThreshold = Settings.getInstance().getThreadCountPerMessageTypeWarningThreshold(); + private final List<PeerAddress> selfPeers = new ArrayList<>(); private String bindAddress = null; @@ -240,6 +255,16 @@ public class Network { private static final Network INSTANCE = new Network(); } + public Map<MessageType, Integer> getThreadsPerMessageType() { + return this.threadsPerMessageType; + } + + public int getTotalThreadCount() { + synchronized (this) { + return this.totalThreadCount; + } + } + public static Network getInstance() { return SingletonContainer.INSTANCE; } @@ -952,6 +977,37 @@ public class Network { // Should be non-handshaking messages from now on + // Limit threads per message type and discard if there are already too many + Integer maxThreadsForMessageType = Settings.getInstance().getMaxThreadsForMessageType(message.getType()); + if (maxThreadsForMessageType != null) { + Integer threadCount = threadsPerMessageType.get(message.getType()); + if (threadCount != null && threadCount >= maxThreadsForMessageType) { + LOGGER.trace("Discarding {} message as there are already {} active threads", message.getType().name(), threadCount); + return; + } + } + + // Warn if necessary + if (threadCountPerMessageTypeWarningThreshold != null) { + Integer threadCount = threadsPerMessageType.get(message.getType()); + if (threadCount != null && threadCount > threadCountPerMessageTypeWarningThreshold) { + LOGGER.info("Warning: high thread count for {} message type: {}", message.getType().name(), threadCount); + } + } + + // Add to per-message thread count (first initializing to 0 if not already present) + threadsPerMessageType.computeIfAbsent(message.getType(), key -> 0); + threadsPerMessageType.computeIfPresent(message.getType(), (key, value) -> value + 1); + + // Add to total thread count + synchronized (this) { + totalThreadCount++; + + if (totalThreadCount >= threadCountWarningThreshold) { + LOGGER.info("Warning: high total thread count: {} / {}", totalThreadCount, Settings.getInstance().getMaxNetworkThreadPoolSize()); + } + } + // Ordered by message type value switch (message.getType()) { case GET_PEERS: @@ -979,6 +1035,15 @@ public class Network { Controller.getInstance().onNetworkMessage(peer, message); break; } + + // Remove from per-message thread count (first initializing to 0 if not already present) + threadsPerMessageType.computeIfAbsent(message.getType(), key -> 0); + threadsPerMessageType.computeIfPresent(message.getType(), (key, value) -> value - 1); + + // Remove from total thread count + synchronized (this) { + totalThreadCount--; + } } private void onHandshakingMessage(Peer peer, Message message, Handshake handshakeStatus) { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index bdff9506..9d25e846 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -29,6 +29,7 @@ import org.qortal.crosschain.Dogecoin.DogecoinNet; import org.qortal.crosschain.Digibyte.DigibyteNet; import org.qortal.crosschain.Ravencoin.RavencoinNet; import org.qortal.crosschain.PirateChain.PirateChainNet; +import org.qortal.network.message.MessageType; import org.qortal.utils.EnumUtils; // All properties to be converted to JSON via JAXB @@ -371,6 +372,58 @@ public class Settings { /** Whether to serve QDN data without authentication */ private boolean qdnAuthBypassEnabled = true; + /** Limit threads per message type */ + private Set<ThreadLimit> maxThreadsPerMessageType = new HashSet<>(); + + /** The number of threads per message type at which a warning should be logged. + * Exclude from settings.json to disable this warning. */ + private Integer threadCountPerMessageTypeWarningThreshold = null; + + + // Domain mapping + public static class ThreadLimit { + private String messageType; + private Integer limit; + + private ThreadLimit() { // makes JAXB happy; will never be invoked + } + + private ThreadLimit(String messageType, Integer limit) { + this.messageType = messageType; + this.limit = limit; + } + + public String getMessageType() { + return messageType; + } + + public void setMessageType(String messageType) { + this.messageType = messageType; + } + + public Integer getLimit() { + return limit; + } + + public void setLimit(Integer limit) { + this.limit = limit; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof ThreadLimit)) + return false; + + return this.messageType.equals(((ThreadLimit) other).getMessageType()); + } + + @Override + public int hashCode() { + return Objects.hash(messageType); + } + } + + // Domain mapping public static class DomainMap { private String domain; @@ -497,6 +550,9 @@ public class Settings { } } while (settings.userPath != null); + // Set some additional defaults if needed + settings.setAdditionalDefaults(); + // Validate settings settings.validate(); @@ -533,6 +589,22 @@ public class Settings { } } + private void setAdditionalDefaults() { + // Populate defaults for maxThreadsPerMessageType. If any are specified in settings.json, they will take priority. + maxThreadsPerMessageType.add(new ThreadLimit("ARBITRARY_DATA_FILE", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("GET_ARBITRARY_DATA_FILE", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("ARBITRARY_DATA", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("GET_ARBITRARY_DATA", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("ARBITRARY_DATA_FILE_LIST", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("GET_ARBITRARY_DATA_FILE_LIST", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("ARBITRARY_SIGNATURES", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("ARBITRARY_METADATA", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("GET_ARBITRARY_METADATA", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("GET_TRANSACTION", 10)); + maxThreadsPerMessageType.add(new ThreadLimit("TRANSACTION_SIGNATURES", 5)); + maxThreadsPerMessageType.add(new ThreadLimit("TRADE_PRESENCES", 5)); + } + // Getters / setters public String getUserPath() { @@ -1054,4 +1126,20 @@ public class Settings { } return this.qdnAuthBypassEnabled; } + + public Integer getMaxThreadsForMessageType(MessageType messageType) { + if (maxThreadsPerMessageType != null) { + for (ThreadLimit threadLimit : maxThreadsPerMessageType) { + if (threadLimit.getMessageType().equals(messageType.name())) { + return threadLimit.getLimit(); + } + } + } + // No entry, so assume unlimited + return null; + } + + public Integer getThreadCountPerMessageTypeWarningThreshold() { + return this.threadCountPerMessageTypeWarningThreshold; + } } From bf270a63ff06ec0f18ee0522f8668a9a99a14899 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Sun, 24 Sep 2023 15:19:07 +0200 Subject: [PATCH 45/57] Update bouncycastle --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 29b7ac58..621c96ae 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ <skipTests>true</skipTests> <altcoinj.version>7dc8c6f</altcoinj.version> <bitcoinj.version>0.15.10</bitcoinj.version> - <bouncycastle.version>1.69</bouncycastle.version> + <bouncycastle.version>1.70</bouncycastle.version> <build.timestamp>${maven.build.timestamp}</build.timestamp> <ciyam-at.version>1.4.1</ciyam-at.version> <commons-net.version>3.8.0</commons-net.version> From 499e2ac3f4d9e103da015bac1d848e8f9e9d67cd Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 10 Oct 2023 23:24:57 +0200 Subject: [PATCH 46/57] hide-failed-trade --- src/main/java/org/qortal/controller/tradebot/TradeBot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 147554dd..c8bc0eba 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -712,7 +712,7 @@ public class TradeBot implements Listener { } try { - List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.CONFIRMED, null, null, null); + List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.BOTH, 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); From 6d202b2b48e17bf5f59e95e7aab8b4f4eaeed4c9 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Thu, 12 Oct 2023 11:23:36 +0200 Subject: [PATCH 47/57] Create settings.json --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7b016a89 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file From db7b17e52e4192d33d017995a276d1b4e628227f Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Fri, 13 Oct 2023 15:57:43 +0200 Subject: [PATCH 48/57] Revert HSQLDB update --- pom.xml | 4 ++-- .../hsqldb/HSQLDBDatabaseUpdates.java | 23 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/pom.xml b/pom.xml index 621c96ae..b7e27464 100644 --- a/pom.xml +++ b/pom.xml @@ -20,12 +20,12 @@ <xz.version>1.9</xz.version> <dagger.version>1.2.2</dagger.version> <guava.version>32.1.2-jre</guava.version> - <hsqldb.version>2.7.2</hsqldb.version> + <hsqldb.version>2.5.1</hsqldb.version> <homoglyph.version>1.2.1</homoglyph.version> <icu4j.version>73.2</icu4j.version> <upnp.version>1.1</upnp.version> <jersey.version>2.29.1</jersey.version> - <jetty.version>9.4.52.v20230823</jetty.version> + <jetty.version>9.4.53.v20231009</jetty.version> <log4j.version>2.20.0</log4j.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <slf4j.version>1.7.36</slf4j.version> diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index c2e9cd62..8763961d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -454,40 +454,40 @@ public class HSQLDBDatabaseUpdates { case 12: // Groups - stmt.execute("CREATE TABLE `Groups` (group_id GroupID, owner QortalAddress NOT NULL, group_name GroupName NOT NULL, " + stmt.execute("CREATE TABLE Groups (group_id GroupID, owner QortalAddress NOT NULL, group_name GroupName NOT NULL, " + "created_when EpochMillis NOT NULL, updated_when EpochMillis, is_open BOOLEAN NOT NULL, " + "approval_threshold TINYINT NOT NULL, min_block_delay INTEGER NOT NULL, max_block_delay INTEGER NOT NULL, " + "reference Signature, creation_group_id GroupID, reduced_group_name GroupName NOT NULL, " + "description GenericDescription NOT NULL, PRIMARY KEY (group_id))"); // For finding groups by name - stmt.execute("CREATE INDEX GroupNameIndex on `Groups` (group_name)"); + stmt.execute("CREATE INDEX GroupNameIndex on Groups (group_name)"); // For finding groups by reduced name - stmt.execute("CREATE INDEX GroupReducedNameIndex on `Groups` (reduced_group_name)"); + stmt.execute("CREATE INDEX GroupReducedNameIndex on Groups (reduced_group_name)"); // For finding groups by owner - stmt.execute("CREATE INDEX GroupOwnerIndex ON `Groups` (owner)"); + stmt.execute("CREATE INDEX GroupOwnerIndex ON Groups (owner)"); // We need a corresponding trigger to make sure new group_id values are assigned sequentially starting from 1 - stmt.execute("CREATE TRIGGER Group_ID_Trigger BEFORE INSERT ON `Groups` " + stmt.execute("CREATE TRIGGER Group_ID_Trigger BEFORE INSERT ON Groups " + "REFERENCING NEW ROW AS new_row FOR EACH ROW WHEN (new_row.group_id IS NULL) " - + "SET new_row.group_id = (SELECT IFNULL(MAX(group_id) + 1, 1) FROM `Groups`)"); + + "SET new_row.group_id = (SELECT IFNULL(MAX(group_id) + 1, 1) FROM Groups)"); // Admins stmt.execute("CREATE TABLE GroupAdmins (group_id GroupID, admin QortalAddress, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, admin), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, admin), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); // For finding groups by admin address stmt.execute("CREATE INDEX GroupAdminIndex ON GroupAdmins (admin)"); // Members stmt.execute("CREATE TABLE GroupMembers (group_id GroupID, address QortalAddress, " + "joined_when EpochMillis NOT NULL, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, address), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, address), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); // For finding groups by member address stmt.execute("CREATE INDEX GroupMemberIndex ON GroupMembers (address)"); // Invites stmt.execute("CREATE TABLE GroupInvites (group_id GroupID, inviter QortalAddress, invitee QortalAddress, " + "expires_when EpochMillis, reference Signature, " - + "PRIMARY KEY (group_id, invitee), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, invitee), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); // For finding invites sent by inviter stmt.execute("CREATE INDEX GroupInviteInviterIndex ON GroupInvites (inviter)"); // For finding invites by group @@ -503,7 +503,7 @@ public class HSQLDBDatabaseUpdates { // NULL expires_when means does not expire! stmt.execute("CREATE TABLE GroupBans (group_id GroupID, offender QortalAddress, admin QortalAddress NOT NULL, " + "banned_when EpochMillis NOT NULL, reason GenericDescription NOT NULL, expires_when EpochMillis, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, offender), FOREIGN KEY (group_id) REFERENCES `Groups` (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, offender), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); // For expiry maintenance stmt.execute("CREATE INDEX GroupBanExpiryIndex ON GroupBans (expires_when)"); break; @@ -1014,5 +1014,4 @@ public class HSQLDBDatabaseUpdates { LOGGER.info(() -> String.format("HSQLDB repository updated to version %d", databaseVersion + 1)); return true; } - -} +} \ No newline at end of file From 404c5d03009aaaa58d7f78f730ebd712d8981686 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Sun, 15 Oct 2023 17:12:02 +0200 Subject: [PATCH 49/57] Filter failed trade after 1 attempt --- .../qortal/controller/tradebot/TradeBot.java | 32 ++++++------------- .../data/transaction/TransactionData.java | 12 +++++-- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index c8bc0eba..e17f642f 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -712,30 +712,16 @@ public class TradeBot implements Listener { } try { - List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.BOTH, 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<TransactionData> transactions = repository.getTransactionRepository().getUnconfirmedTransactions(Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, null, null); - List<TransactionData> 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); + for (TransactionData transactionData : transactions) { + // Treat as failed if buy attempt was more than 60 mins ago (as it's still in the OFFERING state) + if (transactionData.getRecipient().equals(crossChainTradeData.qortalCreatorTradeAddress) && now - transactionData.getTimestamp() > 60*60*1000L) { + failedTrades.put(crossChainTradeData.qortalAtAddress, now); + updatedCrossChainTrades.remove(crossChainTradeData); + } else { + validTrades.put(crossChainTradeData.qortalAtAddress, now); + } } } catch (DataException e) { diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index c4a115df..21628bb9 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -75,6 +75,9 @@ public abstract class TransactionData { @Schema(description = "groupID for this transaction") protected int txGroupId; + @Schema(description = "recipient for this transaction") + protected String recipient; + // Not always present @Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "height of block containing transaction") protected Integer blockHeight; @@ -105,7 +108,7 @@ public abstract class TransactionData { /** Constructor for use by transaction subclasses. */ protected TransactionData(TransactionType type, BaseTransactionData baseTransactionData) { this.type = type; - + this.recipient = baseTransactionData.recipient; this.timestamp = baseTransactionData.timestamp; this.txGroupId = baseTransactionData.txGroupId; this.reference = baseTransactionData.reference; @@ -136,6 +139,10 @@ public abstract class TransactionData { return this.txGroupId; } + public String getRecipient() { + return this.recipient; + } + public void setTxGroupId(int txGroupId) { this.txGroupId = txGroupId; } @@ -250,5 +257,4 @@ public abstract class TransactionData { return Arrays.equals(this.signature, otherTransactionData.signature); } - -} +} \ No newline at end of file From 2af8199d9cc4486fcb1dda2532c2c990b4b5539c Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Mon, 16 Oct 2023 17:27:14 +0200 Subject: [PATCH 50/57] Update dependencies --- pom.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index b7e27464..c8351bfe 100644 --- a/pom.xml +++ b/pom.xml @@ -14,12 +14,12 @@ <ciyam-at.version>1.4.1</ciyam-at.version> <commons-net.version>3.8.0</commons-net.version> <commons-text.version>1.10.0</commons-text.version> - <commons-io.version>2.13.0</commons-io.version> + <commons-io.version>2.14.0</commons-io.version> <commons-compress.version>1.24.0</commons-compress.version> <commons-lang3.version>3.13.0</commons-lang3.version> <xz.version>1.9</xz.version> <dagger.version>1.2.2</dagger.version> - <guava.version>32.1.2-jre</guava.version> + <guava.version>32.1.3-jre</guava.version> <hsqldb.version>2.5.1</hsqldb.version> <homoglyph.version>1.2.1</homoglyph.version> <icu4j.version>73.2</icu4j.version> @@ -35,7 +35,7 @@ <jsoup.version>1.16.1</jsoup.version> <java-diff-utils.version>4.12</java-diff-utils.version> <grpc.version>1.58.0</grpc.version> - <protobuf.version>3.24.3</protobuf.version> + <protobuf.version>3.24.4</protobuf.version> <simplemagic.version>1.17</simplemagic.version> </properties> <build> @@ -51,7 +51,7 @@ <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>versions-maven-plugin</artifactId> - <version>2.16.0</version> + <version>2.16.1</version> <configuration> <generateBackupPoms>false</generateBackupPoms> </configuration> @@ -270,7 +270,7 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> - <version>3.5.0</version> + <version>3.5.1</version> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> <artifactSet> @@ -466,7 +466,7 @@ <dependency> <groupId>org.json</groupId> <artifactId>json</artifactId> - <version>20230618</version> + <version>20231013</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> From 2d599ec3c53f746731412af760fbf81b992e7627 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:39:17 +0200 Subject: [PATCH 51/57] Summary of activity past 24 hours --- .../restricted/resource/AdminResource.java | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/qortal/api/restricted/resource/AdminResource.java b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java index ecb8c6c9..178d7547 100644 --- a/src/main/java/org/qortal/api/restricted/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java @@ -16,11 +16,6 @@ import java.net.InetSocketAddress; import java.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.Paths; -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; @@ -38,7 +33,6 @@ 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.*; @@ -269,7 +263,7 @@ public class AdminResource { @GET @Path("/summary") @Operation( - summary = "Summary of activity since midnight, UTC", + summary = "Summary of activity past 24 hours", responses = { @ApiResponse( content = @Content(schema = @Schema(implementation = ActivitySummary.class)) @@ -282,23 +276,21 @@ public class AdminResource { Security.checkApiCallAllowed(request); ActivitySummary summary = new ActivitySummary(); - - LocalDate date = LocalDate.now(); - LocalTime time = LocalTime.of(0, 0); - ZoneOffset offset = ZoneOffset.UTC; - long start = OffsetDateTime.of(date, time, offset).toInstant().toEpochMilli(); + + long now = NTP.getTime(); + long oneday = now - 24 * 60 * 60 * 1000L; try (final Repository repository = RepositoryManager.getRepository()) { - int startHeight = repository.getBlockRepository().getHeightFromTimestamp(start); + int startHeight = repository.getBlockRepository().getHeightFromTimestamp(oneday); 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.setAssetsIssued(repository.getAssetRepository().getRecentAssetIds(oneday).size()); - summary.setNamesRegistered (repository.getNameRepository().getRecentNames(start).size()); + summary.setNamesRegistered (repository.getNameRepository().getRecentNames(oneday).size()); return summary; } catch (DataException e) { From 7103f41e36eb815410fd47ec8364a821c0b149a5 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:49:53 +0200 Subject: [PATCH 52/57] Reorg pom update dependencies --- pom.xml | 76 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/pom.xml b/pom.xml index c8351bfe..6cc84d15 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ <ciyam-at.version>1.4.1</ciyam-at.version> <commons-net.version>3.8.0</commons-net.version> <commons-text.version>1.10.0</commons-text.version> - <commons-io.version>2.14.0</commons-io.version> + <commons-io.version>2.11.0</commons-io.version> <commons-compress.version>1.24.0</commons-compress.version> <commons-lang3.version>3.13.0</commons-lang3.version> <xz.version>1.9</xz.version> @@ -24,19 +24,39 @@ <homoglyph.version>1.2.1</homoglyph.version> <icu4j.version>73.2</icu4j.version> <upnp.version>1.1</upnp.version> - <jersey.version>2.29.1</jersey.version> + <jaxb-runtime.version>2.3.3</jaxb-runtime.version> + <jersey.version>2.40</jersey.version> <jetty.version>9.4.53.v20231009</jetty.version> <log4j.version>2.20.0</log4j.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <slf4j.version>1.7.36</slf4j.version> <swagger-api.version>2.0.10</swagger-api.version> - <swagger-ui.version>3.23.11</swagger-ui.version> + <swagger-ui.version>3.52.5</swagger-ui.version> <package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> <jsoup.version>1.16.1</jsoup.version> <java-diff-utils.version>4.12</java-diff-utils.version> <grpc.version>1.58.0</grpc.version> <protobuf.version>3.24.4</protobuf.version> <simplemagic.version>1.17</simplemagic.version> + <versions-maven-plugin.version>2.16.1</versions-maven-plugin.version> + <maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version> + <git-commit-id-plugin.version>4.9.10</git-commit-id-plugin.version> + <replacer.version>1.5.3</replacer.version> + <maven-resources-plugin.version>3.3.1</maven-resources-plugin.version> + <build-helper-maven-plugin.version>3.4.0</build-helper-maven-plugin.version> + <maven-jar-plugin.version>3.3.0</maven-jar-plugin.version> + <maven-shade-plugin.version>3.5.1</maven-shade-plugin.version> + <reproducible-build-maven-plugin.version>0.16</reproducible-build-maven-plugin.version> + <maven-surefire-plugin.version>3.1.2</maven-surefire-plugin.version> + <lifecycle-mapping.version>1.0.0</lifecycle-mapping.version> + <build-helper-maven-plugin.version>3.4.0</build-helper-maven-plugin.version> + <json-simple.version>1.1.1</json-simple.version> + <json.version>20231013</json.version> + <extendedset.version>0.12.3</extendedset.version> + <javax.servlet-api.version>4.0.1</javax.servlet-api.version> + <mail.version>1.5.0-b01</mail.version> + <junit-jupiter-engine.version>5.3.1</junit-jupiter-engine.version> + <hamcrest-library.version>1.3</hamcrest-library.version> </properties> <build> <sourceDirectory>src/main/java</sourceDirectory> @@ -51,14 +71,14 @@ <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>versions-maven-plugin</artifactId> - <version>2.16.1</version> + <version>${versions-maven-plugin.version}</version> <configuration> <generateBackupPoms>false</generateBackupPoms> </configuration> </plugin> <plugin> <artifactId>maven-compiler-plugin</artifactId> - <version>3.11.0</version> + <version>${maven-compiler-plugin.version}</version> <configuration> <release>11</release> </configuration> @@ -89,7 +109,7 @@ <plugin> <groupId>pl.project13.maven</groupId> <artifactId>git-commit-id-plugin</artifactId> - <version>4.9.10</version> + <version>${git-commit-id-plugin.version}</version> <executions> <execution> <id>get-the-git-infos</id> @@ -121,7 +141,7 @@ <plugin> <groupId>com.google.code.maven-replacer-plugin</groupId> <artifactId>replacer</artifactId> - <version>1.5.3</version> + <version>${replacer.version}</version> <executions> <execution> <id>replace-swagger-ui</id> @@ -180,7 +200,10 @@ <!-- add swagger-ui as resource to output package --> <plugin> <artifactId>maven-resources-plugin</artifactId> - <version>3.1.0</version> + <version>${maven-resources-plugin.version}</version> + <configuration> + <propertiesEncoding>ISO-8859-1</propertiesEncoding> + </configuration> <executions> <execution> <id>copy-resources</id> @@ -234,7 +257,7 @@ <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> - <version>3.4.0</version> + <version>${build-helper-maven-plugin.version}</version> <executions> <execution> <phase>generate-sources</phase> @@ -252,7 +275,7 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> - <version>3.3.0</version> + <version>${maven-jar-plugin.version}</version> <configuration> <archive> <manifest> @@ -270,13 +293,12 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> - <version>3.5.1</version> + <version>${maven-shade-plugin.version}</version> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> <artifactSet> <excludes> - <!-- Don't include original swagger-UI as we're including our own - modified version --> + <!-- Don't include original swagger-UI as we're including our own modified version --> <exclude>org.webjars:swagger-ui</exclude> <!-- Don't include JUnit as it's for testing only! --> <exclude>junit:junit</exclude> @@ -320,7 +342,7 @@ <plugin> <groupId>io.github.zlika</groupId> <artifactId>reproducible-build-maven-plugin</artifactId> - <version>0.16</version> + <version>${reproducible-build-maven-plugin.version}</version> <executions> <execution> <phase>package</phase> @@ -337,7 +359,7 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> - <version>3.1.2</version> + <version>${maven-surefire-plugin.version}</version> <configuration> <skipTests>${skipTests}</skipTests> </configuration> @@ -349,7 +371,7 @@ <plugin> <groupId>org.eclipse.m2e</groupId> <artifactId>lifecycle-mapping</artifactId> - <version>1.0.0</version> + <version>${lifecycle-mapping.version}</version> <configuration> <lifecycleMappingMetadata> <pluginExecutions> @@ -415,7 +437,7 @@ <dependency> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> - <version>3.4.0</version> + <version>${build-helper-maven-plugin.version}</version> <scope>provided</scope> <!-- needed for build, not for runtime --> </dependency> @@ -461,12 +483,12 @@ <dependency> <groupId>com.googlecode.json-simple</groupId> <artifactId>json-simple</artifactId> - <version>1.1.1</version> + <version>${json-simple.version}</version> </dependency> <dependency> <groupId>org.json</groupId> <artifactId>json</artifactId> - <version>20231013</version> + <version>${json.version}</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> @@ -497,7 +519,7 @@ <dependency> <groupId>io.druid</groupId> <artifactId>extendedset</artifactId> - <version>0.12.3</version> + <version>${extendedset.version}</version> <exclusions> <!-- exclude old versions of jackson-annotations / jackson-core --> <exclusion> @@ -568,12 +590,12 @@ <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> - <version>4.0.1</version> + <version>${javax.servlet-api.version}</version> </dependency> <dependency> <groupId>javax.mail</groupId> <artifactId>mail</artifactId> - <version>1.5.0-b01</version> + <version>${mail.version}</version> </dependency> <!-- Unicode homoglyph utilities --> <dependency> @@ -687,12 +709,12 @@ <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> - <version>5.3.1</version> + <version>${junit-jupiter-engine.version}</version> </dependency> <dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-library</artifactId> - <version>1.3</version> + <version>${hamcrest-library.version}</version> </dependency> --> <!-- BouncyCastle for crypto, including TLS secure networking --> @@ -741,5 +763,11 @@ <artifactId>simplemagic</artifactId> <version>${simplemagic.version}</version> </dependency> + <!-- JAXB runtime for WADL support --> + <dependency> + <groupId>org.glassfish.jaxb</groupId> + <artifactId>jaxb-runtime</artifactId> + <version>${jaxb-runtime.version}</version> + </dependency> </dependencies> </project> From 5cbd4490cc472390648c6f41777b073b081b8054 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Wed, 18 Oct 2023 17:29:10 +0200 Subject: [PATCH 53/57] Back to Bouncycastle 1.69 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 6cc84d15..40d56990 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ <skipTests>true</skipTests> <altcoinj.version>7dc8c6f</altcoinj.version> <bitcoinj.version>0.15.10</bitcoinj.version> - <bouncycastle.version>1.70</bouncycastle.version> + <bouncycastle.version>1.69</bouncycastle.version> <build.timestamp>${maven.build.timestamp}</build.timestamp> <ciyam-at.version>1.4.1</ciyam-at.version> <commons-net.version>3.8.0</commons-net.version> From 53c3f7899aa9f1e3e9a0102e3b234c16d600867e Mon Sep 17 00:00:00 2001 From: QuickMythril <quickmythril@protonmail.com> Date: Thu, 19 Oct 2023 13:48:33 -0400 Subject: [PATCH 54/57] Reward share limit activation timestamp set to 1698508800000 (Sat Oct 28 2023 16:00:00 UTC) --- src/main/resources/blockchain.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 9a26c99e..760da97c 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -22,7 +22,8 @@ "maxRewardSharesPerFounderMintingAccount": 6, "maxRewardSharesByTimestamp": [ { "timestamp": 0, "maxShares": 6 }, - { "timestamp": 1657382400000, "maxShares": 3 } + { "timestamp": 1657382400000, "maxShares": 3 }, + { "timestamp": 1698508800000, "maxShares": 2 } ], "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 43200000, From 033b6adb7230ec3afefa03f5580feb07cb737056 Mon Sep 17 00:00:00 2001 From: QuickMythril <quickmythril@protonmail.com> Date: Thu, 19 Oct 2023 13:51:53 -0400 Subject: [PATCH 55/57] Bump version to 4.3.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 40d56990..e623a5dd 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ <modelVersion>4.0.0</modelVersion> <groupId>org.qortal</groupId> <artifactId>qortal</artifactId> - <version>4.3.0</version> + <version>4.3.1</version> <packaging>jar</packaging> <properties> <skipTests>true</skipTests> From 6525cac66c113f30da2aa495bc4343d34e2d2185 Mon Sep 17 00:00:00 2001 From: karl-dv <38753527+karl-dv@users.noreply.github.com> Date: Sat, 28 Oct 2023 09:11:08 +0200 Subject: [PATCH 56/57] Manual review of "nl" translations --- .../resources/i18n/ApiError_nl.properties | 40 +++++----- src/main/resources/i18n/SysTray_nl.properties | 18 ++--- .../i18n/TransactionValidity_nl.properties | 76 +++++++++---------- 3 files changed, 67 insertions(+), 67 deletions(-) diff --git a/src/main/resources/i18n/ApiError_nl.properties b/src/main/resources/i18n/ApiError_nl.properties index 0501fe9a..8c8a3b09 100644 --- a/src/main/resources/i18n/ApiError_nl.properties +++ b/src/main/resources/i18n/ApiError_nl.properties @@ -4,36 +4,36 @@ # "localeLang": "nl", ### Common ### -JSON = lezen van JSON bericht gefaald +JSON = lezen van JSON bericht is mislukt -INSUFFICIENT_BALANCE = insufficient balance +INSUFFICIENT_BALANCE = onvoldoende saldo UNAUTHORIZED = ongeautoriseerde API call -REPOSITORY_ISSUE = repository fout +REPOSITORY_ISSUE = fout in repository NON_PRODUCTION = deze API call is niet toegestaan voor productiesystemen -BLOCKCHAIN_NEEDS_SYNC = blockchain dient eerst gesynchronizeerd te worden +BLOCKCHAIN_NEEDS_SYNC = blockchain dient eerst te synchronizeren -NO_TIME_SYNC = klok nog niet gesynchronizeerd +NO_TIME_SYNC = klok is nog niet gesynchronizeerd ### Validation ### -INVALID_SIGNATURE = ongeldige handtekening +INVALID_SIGNATURE = ongeldige signature INVALID_ADDRESS = ongeldig adres INVALID_PUBLIC_KEY = ongeldige public key -INVALID_DATA = ongeldige gegevens +INVALID_DATA = ongeldige data INVALID_NETWORK_ADDRESS = ongeldig netwerkadres -ADDRESS_UNKNOWN = account adres onbekend +ADDRESS_UNKNOWN = account-adres onbekend INVALID_CRITERIA = ongeldige zoekcriteria -INVALID_REFERENCE = ongeldige verwijzing +INVALID_REFERENCE = ongeldige referentie TRANSFORMATION_ERROR = JSON kon niet omgezet worden in transactie @@ -44,10 +44,10 @@ INVALID_HEIGHT = ongeldige blokhoogte CANNOT_MINT = account kan niet minten ### Blocks ### -BLOCK_UNKNOWN = blok onbekend +BLOCK_UNKNOWN = blok niet gekend ### Transactions ### -TRANSACTION_UNKNOWN = onbekende transactie +TRANSACTION_UNKNOWN = transactie niet gekend PUBLIC_KEY_NOT_FOUND = public key niet gevonden @@ -55,29 +55,29 @@ PUBLIC_KEY_NOT_FOUND = public key niet gevonden TRANSACTION_INVALID = ongeldige transactie: %s (%s) ### Naming ### -NAME_UNKNOWN = onbekende naam +NAME_UNKNOWN = naam niet gekend ### Asset ### INVALID_ASSET_ID = ongeldige asset ID INVALID_ORDER_ID = ongeldige asset order ID -ORDER_UNKNOWN = onbekende asset order ID +ORDER_UNKNOWN = niet gekende asset order ID ### Groups ### -GROUP_UNKNOWN = onbekende groep +GROUP_UNKNOWN = groep niet gekend ### Foreign Blockchain ### -FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = blockchain of ElectrumX network probleem +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = vreemde blockchain of ElectrumX networkprobleem -FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = onvoldoende saldo blockchain +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = onvoldoende saldo bij vreemde blockchain -FOREIGN_BLOCKCHAIN_TOO_SOON = nog niet gereed om de blockchain transactie uittevoeren (LockTime/median block time) +FOREIGN_BLOCKCHAIN_TOO_SOON = te vroeg om de blockchain transactie uit te sturen (LockTime/median block time) ### Trade Portal ### -ORDER_SIZE_TOO_SMALL = order bedrag te laag +ORDER_SIZE_TOO_SMALL = order-bedrag te laag ### Data ### -FILE_NOT_FOUND = file niet gevonden +FILE_NOT_FOUND = bestand niet gevonden -NO_REPLY = peer reageerd niet met data +NO_REPLY = peer reageerde niet binnen toegelaten tijd diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties index c2acb7ce..3d7de024 100644 --- a/src/main/resources/i18n/SysTray_nl.properties +++ b/src/main/resources/i18n/SysTray_nl.properties @@ -1,17 +1,17 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -APPLYING_UPDATE_AND_RESTARTING = Automatische update en herstart worden uitgevoerd... +APPLYING_UPDATE_AND_RESTARTING = Bezig met automatische update en herstart... AUTO_UPDATE = Automatische Update -BLOCK_HEIGHT = Block hoogte +BLOCK_HEIGHT = blok-hoogte -BLOCKS_REMAINING = blocks remaining +BLOCKS_REMAINING = overblijvende blokken -BUILD_VERSION = Versie nummer +BUILD_VERSION = Versienummer -CHECK_TIME_ACCURACY = Controleer accuraatheid van de tijd +CHECK_TIME_ACCURACY = Controleer of de tijd correct is CONNECTING = Verbinden @@ -33,16 +33,16 @@ LITE_NODE = Lite node MINTING_DISABLED = Minten is uitgeschakeld -MINTING_ENABLED = \u2714 Minten is ingeschakeld +MINTING_ENABLED = \u2714 Minten is actief OPEN_UI = Open UI -PERFORMING_DB_CHECKPOINT = De database wordt gecontroleerd... +PERFORMING_DB_CHECKPOINT = De database wordt bijgewerkt... -PERFORMING_DB_MAINTENANCE = Uitvoeren van gepland onderhoud... +PERFORMING_DB_MAINTENANCE = Bezig met gepland onderhoud... SYNCHRONIZE_CLOCK = Synchronizeer klok -SYNCHRONIZING_BLOCKCHAIN = Aan het synchronizeren +SYNCHRONIZING_BLOCKCHAIN = Bezig met synchronizeren SYNCHRONIZING_CLOCK = Klok wordt gesynchronizeerd diff --git a/src/main/resources/i18n/TransactionValidity_nl.properties b/src/main/resources/i18n/TransactionValidity_nl.properties index 36b0fec9..f92adf72 100644 --- a/src/main/resources/i18n/TransactionValidity_nl.properties +++ b/src/main/resources/i18n/TransactionValidity_nl.properties @@ -1,28 +1,28 @@ # -ACCOUNT_ALREADY_EXISTS = account bestaat al +ACCOUNT_ALREADY_EXISTS = account bestaat reeds ACCOUNT_CANNOT_REWARD_SHARE = account kan geen beloningen delen -ADDRESS_ABOVE_RATE_LIMIT = adres heeft een waarde limiet bereikt +ADDRESS_ABOVE_RATE_LIMIT = adres heeft opgegeven limietwaarde bereikt -ADDRESS_BLOCKED = adres is geblokkeerd +ADDRESS_BLOCKED = dit adres is geblokkeerd -ALREADY_GROUP_ADMIN = groeps administrator bestaat al +ALREADY_GROUP_ADMIN = reeds gekend als groepsadministrator -ALREADY_GROUP_MEMBER = groeps lid bestaat al +ALREADY_GROUP_MEMBER = reeds gekend als groepslid ALREADY_VOTED_FOR_THAT_OPTION = reeds gestemd voor die optie -ASSET_ALREADY_EXISTS = asset bestaat al +ASSET_ALREADY_EXISTS = asset bestaat reeds ASSET_DOES_NOT_EXIST = asset bestaat niet ASSET_DOES_NOT_MATCH_AT = asset komt niet overeen met de asset van de AT -ASSET_NOT_SPENDABLE = asset is niet toerijkend +ASSET_NOT_SPENDABLE = asset kan niet uitbetaald worden -AT_ALREADY_EXISTS = AT bestaat al +AT_ALREADY_EXISTS = AT bestaat reeds AT_IS_FINISHED = AT is afgelopen @@ -38,25 +38,25 @@ BUYER_ALREADY_OWNER = koper is al de eigenaar CLOCK_NOT_SYNCED = klok is niet gesynchronizeerd -DUPLICATE_MESSAGE = dubbel adres bericht +DUPLICATE_MESSAGE = adres heeft dubbel bericht verzonden DUPLICATE_OPTION = dubbele optie GROUP_ALREADY_EXISTS = groep bestaat reeds -GROUP_APPROVAL_DECIDED = groeps goedkeuring afgewezen +GROUP_APPROVAL_DECIDED = groeps-goedkeuring afgewezen -GROUP_APPROVAL_NOT_REQUIRED = groeps goedkeuring niet vereist +GROUP_APPROVAL_NOT_REQUIRED = groeps-goedkeuring niet vereist GROUP_DOES_NOT_EXIST = groep bestaat niet GROUP_ID_MISMATCH = groeps ID komt niet overeen -GROUP_OWNER_CANNOT_LEAVE = groep eigenaar kan de groep niet verlaten +GROUP_OWNER_CANNOT_LEAVE = groep-eigenaar kan groep niet verlaten -HAVE_EQUALS_WANT = asset is gelijk aan Want-asset +HAVE_EQUALS_WANT = asset is gelijk aan Wens-asset -INCORRECT_NONCE = incorrecte PoW nonce +INCORRECT_NONCE = foutieve PoW nonce INSUFFICIENT_FEE = vergoeding te laag @@ -72,19 +72,19 @@ INVALID_AT_TYPE_LENGTH = ongeldige lengte voor AT type INVALID_BUT_OK = ongeldig maar is in orde -INVALID_CREATION_BYTES = ongeldige gecreerde bytes +INVALID_CREATION_BYTES = ongeldige creatie-bytes -INVALID_DATA_LENGTH = ongeldige data lengte +INVALID_DATA_LENGTH = ongeldige data-lengte -INVALID_DESCRIPTION_LENGTH = ongeldige lengte voor de beschrijving +INVALID_DESCRIPTION_LENGTH = ongeldige lengte voor beschrijving INVALID_GROUP_APPROVAL_THRESHOLD = ongeldige drempelwaarde voor groepsgoedkeuring -INVALID_GROUP_BLOCK_DELAY = ongeldige groep blok vertraging +INVALID_GROUP_BLOCK_DELAY = ongeldige blok-vertraging bij groepsgoedkeuring INVALID_GROUP_ID = ongeldige groep-ID -INVALID_GROUP_OWNER = ongeldige groep eigenaar +INVALID_GROUP_OWNER = ongeldige groep-eigenaar INVALID_LIFETIME = ongeldige levensduur @@ -94,27 +94,27 @@ INVALID_NAME_OWNER = ongeldige naam voor eigenaar INVALID_OPTION_LENGTH = ongeldige lengte voor opties -INVALID_OPTIONS_COUNT = ongeldige hoeveelheid opties +INVALID_OPTIONS_COUNT = ongeldig aantal opties -INVALID_ORDER_CREATOR = ongeldige gebruiker voor deze order +INVALID_ORDER_CREATOR = ongeldige order-creatie-gebruiker -INVALID_PAYMENTS_COUNT = ongeldige betalings waarde +INVALID_PAYMENTS_COUNT = ongeldig aantal betalingen INVALID_PUBLIC_KEY = ongeldige public key INVALID_QUANTITY = ongeldige hoeveelheid -INVALID_REFERENCE = ongeldige verwijzing +INVALID_REFERENCE = ongeldige referentie -INVALID_RETURN = ongeldige return +INVALID_RETURN = ongeldig resultaat -INVALID_REWARD_SHARE_PERCENT = ongeldig belonings percentage +INVALID_REWARD_SHARE_PERCENT = ongeldig belonings-deelpercentage INVALID_SELLER = ongeldige verkoper INVALID_TAGS_LENGTH = ongeldige lengte voor 'tags' -INVALID_TIMESTAMP_SIGNATURE = ongeldig tijd aanduiding +INVALID_TIMESTAMP_SIGNATURE = ongeldig tijd-aanduiding INVALID_TX_GROUP_ID = ongeldige transactiegroep-ID @@ -124,15 +124,15 @@ INVITE_UNKNOWN = onbekende groepsuitnodiging JOIN_REQUEST_EXISTS = aanvraag om lid van groep te worden bestaat al -MAXIMUM_REWARD_SHARES = limiet aan belonen voor dit account bereikt +MAXIMUM_REWARD_SHARES = maximum bereikt voor beloning-delen voor dit account -MISSING_CREATOR = ontbrekende aanmaker +MISSING_CREATOR = creator niet gekend MULTIPLE_NAMES_FORBIDDEN = het registreren van meerdere namen op een account is niet toegestaan -NAME_ALREADY_FOR_SALE = naam reeds te koop +NAME_ALREADY_FOR_SALE = naam is reeds te koop -NAME_ALREADY_REGISTERED = naam reeds geregistreerd +NAME_ALREADY_REGISTERED = naam is reeds geregistreerd NAME_BLOCKED = deze naam is geblokkeerd @@ -140,7 +140,7 @@ NAME_DOES_NOT_EXIST = naam bestaat niet NAME_NOT_FOR_SALE = naam is niet te koop -NAME_NOT_NORMALIZED = naam voldoet niet aan Unicode-vorm +NAME_NOT_NORMALIZED = naam niet in Unicode-'nomaal'-vorm NEGATIVE_AMOUNT = negatieve hoeveelheid @@ -148,9 +148,9 @@ NEGATIVE_FEE = negatieve vergoeding NEGATIVE_PRICE = negatieve prijs -NO_BALANCE = onvoldoende balans +NO_BALANCE = onvoldoende saldo -NO_BLOCKCHAIN_LOCK = geen blockchain slot +NO_BLOCKCHAIN_LOCK = blockchain op node is momenteel bezet NO_FLAG_PERMISSION = account heeft hier geen toestemming voor @@ -176,9 +176,9 @@ POLL_OPTION_DOES_NOT_EXIST = peilingsoptie bestaat niet PUBLIC_KEY_UNKNOWN = public key onbekend -REWARD_SHARE_UNKNOWN = beloning vergoeding onbekend +REWARD_SHARE_UNKNOWN = beloningsdeel is onbekend -SELF_SHARE_EXISTS = zelf vergoeding bestaat reeds +SELF_SHARE_EXISTS = zelf-beloning (belonings-delen) bestaat reeds TIMESTAMP_TOO_NEW = tijdstempel te nieuw @@ -186,10 +186,10 @@ TIMESTAMP_TOO_OLD = tijdstempel te oud TOO_MANY_UNCONFIRMED = account heeft te veel onbevestigde transacties in afwachting -TRANSACTION_ALREADY_CONFIRMED = transactie is reeds bevestigd +TRANSACTION_ALREADY_CONFIRMED = transactie werd reeds bevestigd -TRANSACTION_ALREADY_EXISTS = transactie bestaat al +TRANSACTION_ALREADY_EXISTS = transactie bestaat reeds TRANSACTION_UNKNOWN = transactie onbekend -TX_GROUP_ID_MISMATCH = groep ID komt niet overeen +TX_GROUP_ID_MISMATCH = groep-ID komt niet overeen From 9b64f12b7affbb6b35b8daa31f75a6d731a6df4c Mon Sep 17 00:00:00 2001 From: QuickMythril <quickmythril@protonmail.com> Date: Mon, 30 Oct 2023 22:01:48 -0400 Subject: [PATCH 57/57] Default minPeerVersion set to 4.3.1 --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 679fe120..babef614 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -226,7 +226,7 @@ public class Settings { public long recoveryModeTimeout = 9999999999999L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "4.3.0"; + private String minPeerVersion = "4.3.1"; /** 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 */