diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a9cebd..218e1c1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,28 +3,45 @@ name: build-all on: push: branches: - - main - - dev + - "**" tags: - "v**" pull_request: branches: - - main - - dev + - "**" workflow_dispatch: permissions: contents: write +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + jobs: build_linux: - runs-on: ubuntu-22.04 + strategy: + # One arch must never cancel the others: a broken arm64 build must not + # nuke the x64 signal (or vice versa). + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + artifact: xlite-daemon-linux64 + artifact_name: artifacts-linux-x64 + native_arch: x86-64 + - os: ubuntu-22.04-arm + artifact: xlite-daemon-linux-arm64 + artifact_name: artifacts-linux-arm64 + # GraalVM native-image on AArch64 accepts armv8-a (not aarch64). + native_arch: armv8-a + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 if: github.event.pull_request.draft == false - # - name: Setup GraalVM JDK 21 via SDKMAN (FOR LOCAL TESTING WITH ACT) + # - name: Setup GraalVM JDK 21 via SDKMAN (FOR TESTING WITH ACT) # shell: bash # run: | # curl -s "https://get.sdkman.io" | bash @@ -37,44 +54,58 @@ jobs: uses: graalvm/setup-graalvm@v1 with: java-version: '21' - distribution: 'graalvm-community' + distribution: 'graalvm-community' - name: Verify Java installation run: | java -version - native-image --version + native-image --version - - name: Make gradlew executable - run: chmod +x gradlew + - name: Make Maven wrapper executable + run: chmod +x mvnw - name: Build native image - run: ./gradlew nativeCompile --info + run: ./mvnw clean package -Pnative -DskipTests -Dnative.march=${{ matrix.native_arch }} - name: Make daemon executable - run: chmod +x build/native/nativeCompile/xlite-daemon + run: chmod +x target/xlite-daemon - name: Rename executable - run: mv build/native/nativeCompile/xlite-daemon build/native/nativeCompile/xlite-daemon-linux64 + run: mv target/xlite-daemon target/${{ matrix.artifact }} - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: artifacts-linux + name: ${{ matrix.artifact_name }} path: | - build/native/nativeCompile/xlite-daemon-linux64 + target/${{ matrix.artifact }} - name: Create release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/v') with: name: XLite Daemon ${{ github.ref_name}} prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} - generate_release_notes: true files: | - build/native/nativeCompile/xlite-daemon-linux64 + target/${{ matrix.artifact }} build_mac: - runs-on: macos-15-intel + strategy: + # One arch must never cancel the others: a broken arm64 build must not + # nuke the x64 signal (or vice versa). + fail-fast: false + matrix: + include: + - os: macos-15-intel + artifact: xlite-daemon-osx64 + artifact_name: artifacts-mac-x64 + native_arch: x86-64 + - os: macos-15 + artifact: xlite-daemon-osx-arm64 + artifact_name: artifacts-mac-arm64 + # GraalVM native-image on AArch64 accepts armv8-a (not aarch64). + native_arch: armv8-a + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -84,44 +115,58 @@ jobs: uses: graalvm/setup-graalvm@v1 with: java-version: '21' - distribution: 'graalvm-community' + distribution: 'graalvm-community' - name: Verify Java installation run: | java -version - native-image --version + native-image --version - - name: Make gradlew executable - run: chmod +x gradlew + - name: Make Maven wrapper executable + run: chmod +x mvnw - name: Build native image - run: ./gradlew nativeCompile --info + run: ./mvnw clean package -Pnative -DskipTests -Dnative.march=${{ matrix.native_arch }} - name: Make daemon executable - run: chmod +x build/native/nativeCompile/xlite-daemon + run: chmod +x target/xlite-daemon - name: Rename executable - run: mv build/native/nativeCompile/xlite-daemon build/native/nativeCompile/xlite-daemon-osx64 + run: mv target/xlite-daemon target/${{ matrix.artifact }} - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: artifacts-mac + name: ${{ matrix.artifact_name }} path: | - build/native/nativeCompile/xlite-daemon-osx64 + target/${{ matrix.artifact }} - name: Create release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/v') with: name: XLite Daemon ${{ github.ref_name}} prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} - generate_release_notes: true files: | - build/native/nativeCompile/xlite-daemon-osx64 + target/${{ matrix.artifact }} build_win: - runs-on: windows-2022 + strategy: + # One arch must never cancel the others: a broken arm64 build must not + # nuke the x64 signal (or vice versa). + fail-fast: false + matrix: + include: + - os: windows-2022 + artifact: xlite-daemon-win64.exe + artifact_name: artifacts-win-x64 + native_arch: x86-64 + # No windows-11-arm native entry: no GraalVM distribution ships a + # windows-aarch64 backend (oracle/graal#9215; cross-compilation + # unsupported per oracle/graal#407; Liberica NIK is x64-only on + # Windows). Windows ARM64 users run the x64 binary via the OS + # emulator until upstream support lands. + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -131,38 +176,33 @@ jobs: uses: graalvm/setup-graalvm@v1 with: java-version: '21' - distribution: 'graalvm-community' + distribution: 'graalvm-community' - name: Verify Java installation run: | java -version native-image --version - - name: Install Windows SDK - run: choco install -y windows-sdk-10.0 - - - name: Install Visual Studio Build Tools & sdk - run: choco install -y visualstudio2022-workload-vctools windows-sdk-10.0 + - name: Install Visual Studio Build Tools + run: | + choco install -y visualstudio2022buildtools --installargs "--add Microsoft.VisualStudio.Workload.VCTools --quiet --wait" - name: Build native image - run: ./gradlew.bat nativeCompile --info + run: .\mvnw.cmd clean package -Pnative -DskipTests "-Dnative.march=${{ matrix.native_arch }}" - name: Rename executable - run: ren build\native\nativeCompile\xlite-daemon.exe xlite-daemon-win64.exe + run: Rename-Item target\xlite-daemon.exe ${{ matrix.artifact }} - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: artifacts-win - path: | - build\native\nativeCompile\xlite-daemon-win64.exe + name: ${{ matrix.artifact_name }} + path: target\${{ matrix.artifact }} - name: Create release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/v') with: name: XLite Daemon ${{ github.ref_name}} prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} - generate_release_notes: true - files: | - build\native\nativeCompile\xlite-daemon-win64.exe \ No newline at end of file + files: target/${{ matrix.artifact }} diff --git a/.gitignore b/.gitignore index cf1ea04..5e37cd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,45 @@ +# Lock files and build artifacts .lock/ -.gradle/ /bin/ /build/ /builds/ .classpath .history/ .vscode/ -.gradle/ .env .aider* -.idea* \ No newline at end of file +.idea* +.kilo* +# Maven-specific ignores +target/ +dependency-reduced-pom.xml +*.jar +*.war +*.ear +*.zip +*.tar.gz +*.rar + +# Compiled class files +*.class + +# Log files +logs/ +*.log +log/ + +# IDE output directories +out/ + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +*~ + +xlite-daemon/* +.settings +.project \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/.project b/.project deleted file mode 100644 index 41a5c27..0000000 --- a/.project +++ /dev/null @@ -1,34 +0,0 @@ - - - cc-daemon - Project cc-daemon created by Buildship. - - - - - org.eclipse.jdt.core.javabuilder - - - - - org.eclipse.buildship.core.gradleprojectbuilder - - - - - - org.eclipse.jdt.core.javanature - org.eclipse.buildship.core.gradleprojectnature - - - - 1684135176474 - - 30 - - org.eclipse.core.resources.regexFilterMatcher - node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ - - - - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..31c3f05 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +# AGENTS.md + +## Project Overview + +XLite Daemon — a multi-cryptocurrency wallet daemon built with Java 21 and Maven. +Core packages: `crypto` (wallet encryption/key management), `net` (coin networking, JSON-RPC), +`util` (config, address discovery, logging), `wallet` (wallet helpers). + +## Data Providers + +This is the **client-side** daemon behind the XLite wallet GUI: it serves one +local JSON-RPC port per coin (`net/api/http/master/`) and fetches all blockchain +data upstream over HTTP (`net/api/http/client/`). + +- **EXR mode:** CLI `--exr-endpoint ` or env `EXR_ENDPOINT` + (comma-separated pool → `EXRServerPool`). Capabilities are probed from GET + `/xrs/heights`; health checks every 5 s; requests route to a server that + supports the coin. Calls hit `/xrs/` — GET `heights`/`fees`, + POST coin-first-param methods (`getutxos`, `sendrawtransaction`, …). Once EXR + is configured there is **NO fallback** to BASE_URL — requests for coins no EXR + server supports fail hard. +- **Legacy mode (default):** `BASE_URL` = `https://xliterevp.mywire.org/` in + `App.java`; override with `--development-endpoint`. POST `/` with + `{method, params}` JSON. +- Upstream chain: exrproxy `/xrs/` → plugin-adapter xrm methods → + utxo-plugin containers. +- `net/xrouter/` and the `xrm*` handling in `BlocknetPeerGroup` are currently + disabled XRouter-over-p2p paths (commented out at `CoinInstance.java:517-557`) + — **work-in-progress, NOT abandoned**: never delete, prune, or "clean up" them; + treat every Blocknet p2p/XRouter extra (serializers, packet magic, peer-group + plumbing) as live WIP. Any earlier "dead legacy" wording here was wrong; the + workspace-root rule takes precedence. + +# Java — use jabba +source ~/.jabba/jabba.sh && jabba use graalvm_community@21.0.2 + +## Build & Test Commands + +```bash +# Compile +mvn compile -q + +# Run all tests +mvn test + +# OpenRewrite code cleanup (opt-in; NOT part of routine builds) +mvn -Prewrite process-classes + +# Run a single test class +mvn test -pl . -Dtest=KeyHandlerTest + +# Run a single test method +mvn test -pl . -Dtest=KeyHandlerTest#testGetBaseSeedRoundTrip + +# Build shaded JAR +mvn package -q + +# Build GraalVM native image +mvn package -Pnative -Pnative-fast -q + +# Requirements: Java 21 (jabba: graalvm_community@21.0.2), Maven 3.8.6+ +``` + +## Code Style + +### Imports + +- Group order: third-party libraries, then `java.*`, then `javax.*` +- Wildcard imports are acceptable for large groups (e.g., `java.io.*`, `org.bitcoinj.core.*`) +- No unused imports; OpenRewrite cleanup is opt-in via `mvn -Prewrite process-classes` + +### Formatting + +- 4-space indentation, no tabs +- Opening braces on same line +- Blank line between methods, between logical sections within methods +- No trailing whitespace + +### Logging + +Every class uses this logger pattern: + +```java +private final static LogManager LOGMANAGER = LogManager.getLogManager(); +private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); +``` + +Log levels: `SEVERE` for critical failures, `WARNING` for recoverable errors, +`INFO` for operational events, `FINER` for debugging (e.g., wrong password). + +Log messages use bracketed prefixes: `[security]`, `[discovery-BLOCK]`, `[wallet]`. + +### Naming + +- Classes: `PascalCase` +- Methods/variables: `camelCase` +- Constants: `UPPER_SNAKE_CASE` +- Test classes: `Test.java` +- Test methods: `test` (e.g., `testGetBaseSeedRoundTrip`, `testDiscovery_FindsUsedAddresses`) +- Tests use `@TestMethodOrder(OrderAnnotation.class)` + `@Order(n)` for sequencing +- Use `static import org.junit.jupiter.api.Assertions.*` for assertions + +### Error Handling & Security + +- Crypto ops: try/finally with `Arrays.fill(bytes, (byte) 0)` to clear sensitive data +- Call `PBEKeySpec.clearPassword()` after key derivation +- Never use `e.printStackTrace()` — use `LOGGER.level(msg + e.getMessage());` +- Catch specific exceptions before generic `Exception` +- `RuntimeException` for unrecoverable state; return `null`/`false` for expected failures +- AES-CBC + random IV for new encryption; ECB only for legacy migration +- PBKDF2WithHmacSHA256, 100k iterations; `SecureRandom.getInstanceStrong()` for RNG +- Always use `StandardCharsets.UTF_8` for `getBytes()` calls +- Passphrases use `char[]` — `String` is unsupported (immutable, cannot be wiped) + +## Project Structure + +``` +src/main/java/io/xlite/daemon/app/ + crypto/ KeyHandler (wallet encryption/key management) + net/ CoinInstance (coin lifecycle), JSON-RPC servers, protocols/ + util/ ConfigHelper, AddressDiscoveryService, UTXO, logging + wallet/ WalletHelper + App.java Main entry point + +src/test/java/ + KeyHandlerTest, CoinInstanceTest, ConfigHelperTest, + AddressDiscoveryServiceTest, TestHelper +``` + +## Key Dependencies + +| Library | Purpose | +|---------|---------| +| bitcoinj-core 0.15.10 | Bitcoin/crypto, MnemonicCode, ECKey | +| Gson 2.13.2 | JSON serialization | +| Netty 4.2.7 | HTTP servers, networking | +| Guava 28.2-android | Joiner, Preconditions, AtomicDouble | +| Orchid 1.2.1 | Hex/Base64 encoders | +| httpclient 4.5.14 | HTTP client | +| json 20250517 | JSONObject/JSONArray | +| java-dotenv 5.2.2 | .env file support | +| JUnit Jupiter 5.11.3 | Test framework | +| Mockito 5.15.2 | Test mocking | + +## Commit Message Style + +Recent commits use conventional commits: `type(scope): description` + +Types: `feat`, `fix`, `chore`, `refactor`, `build`, `security`, `test`. + +Some older commits use `[category] description` style (e.g., `[security] Upgrade wallet encryption`). + +## Things to Watch For + +- The `rewrite-maven-plugin` no longer runs in the default build (it cost ~8s + per build); it executes only under the `-Prewrite` profile and may auto-modify + imports and formatting. Run it before releases or style sweeps, and always + review `git diff` afterwards. +- `ConfigHelper.CONFIG_DIR` is a mutable static used to override config path in tests. +- Tests use `@TempDir` (JUnit 5 auto-cleanup); never run with parallel execution + due to mutable `ConfigHelper.CONFIG_DIR` static state. +- Mockito requires ByteBuddy agent (configured in pom.xml surefire plugin). +- App reads `.env` via java-dotenv (`App.getEnv()` wraps `Dotenv`). +- Javadoc uses `

` tags and `{@code}` inline; section dividers use `// ===` / `// ---`. +- The enforcer plugin requires Java 21 and Maven 3.8.6+. diff --git a/README.md b/README.md index 720df77..503d2ac 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Xlite Wallet Backend -The Xlite Wallet Backend is a Java-based project that serves as the backend infrastructure for the Xlite wallet application. It is built using Java with JDK version 21 and utilizes Gradle for build automation. The project incorporates the org.bitcoinj library version 0.14.7 for Bitcoin-related functionality. +The Xlite Wallet Backend is a Java-based project that serves as the backend infrastructure for the Xlite wallet application. It is built using Java with JDK version 21 and utilizes Maven for build automation. The project incorporates the org.bitcoinj library version 0.14.7 for Bitcoin-related functionality. ## Table of Contents - [Project Overview](#project-overview) - [Prerequisites](#prerequisites) - [Getting Started](#getting-started) +- [Maven Build Commands](#maven-build-commands) - [Usage](#usage) - [Configuration](#configuration) - [Contributing](#contributing) @@ -21,6 +22,7 @@ Provide a brief description of the Xlite Wallet Backend project. Explain its pur List the prerequisites required to set up and run the Xlite Wallet Backend. Include the following: - JDK 21: Install the Java Development Kit version 21 or a compatible version. +- Maven 3.8.6 or higher: Install Apache Maven for build automation. ## Getting Started @@ -34,18 +36,59 @@ git clone https://github.com/blocknetdx/xlite-daemon ``` cd xlite-daemon -# mac/linux: -chmod +x gradlew -./gradlew nativeImage +# Make Maven wrapper executable (mac/linux): +chmod +x mvnw -# windows: -nativeImageWindows.bat +# Build native image: +./mvnw clean package -Pnative + +# Or for faster build without tests: +./mvnw clean package -Pnative-fast ``` 3. Configuration: If any configuration files or settings need to be modified, provide instructions on how to set them up. -4. Run the application: +4. Run the application: +``` +./target/xlite-daemon +``` + +## Maven Build Commands + +### Basic Maven Operations + +```bash +# Clean and compile +mvn clean compile + +# Run tests +mvn test + +# Package JAR (without native compilation) +mvn package -DskipTests + +# Build native image +mvn clean package -Pnative + +# Run application +mvn exec:java + +# Skip tests for faster builds +mvn clean package -Pnative-fast ``` -binary to find in build/graal/ folder + +### Profile-Specific Commands + +- **native**: Full native image compilation with all optimizations +- **native-fast**: Faster native compilation with reduced optimizations for development + +### Common Maven Goals + +- `mvn clean`: Remove build artifacts +- `mvn compile`: Compile source code +- `mvn test`: Run unit tests +- `mvn package`: Package compiled code into distributable format +- `mvn install`: Install package into local repository +- `mvn dependency:tree`: Display dependency tree ``` ## Usage @@ -69,6 +112,7 @@ gettxout - Get info about an unspent transaction output =====Network===== getinfo - Get information such as balances, protocol version, and more getnetworkinfo - Get network information +getfees - Get fee per byte and minimum transaction fee for the current coin getrawmempool - Get raw mempool getblockchaininfo - Get blockchain info getblockhash - Get the hash of a block at a given height @@ -99,15 +143,20 @@ xlite-daemon (Backend) Configuration Files: ``` Windows -%appdata%\CloudChains\settings\config-*.json +%appdata%\xlite-daemon\settings\config-*.json MacOS -~/Library/Application Support/CloudChains/settings/config-*.json +~/Library/Application Support/xlite-daemon/settings/config-*.json Linux -~/.config/CloudChains/settings/config-*.json +~/.config/xlite-daemon/settings/config-*.json ``` +Set `XLITE_DATA_HOME` to override the platform directory above. The +daemon aborts at startup when no override is set and the platform +default is missing (Windows with unset `%AppData%`, or an empty +`user.home`). + ## Contributing diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 123e51d..0000000 --- a/build.gradle +++ /dev/null @@ -1,118 +0,0 @@ -plugins { - id 'application' - id 'org.graalvm.buildtools.native' version '0.11.3' - id 'com.gradleup.shadow' version '9.3.0' -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -application { - mainClass = 'io.cloudchains.app.App' -} - -ext { - bitcoinjVersion = '0.14.7' - nettyVersion = '4.1.115.Final' - junitVersion = '5.11.3' - slf4jVersion = '2.0.16' - gsonVersion = '2.13.2' -} - -version = "0.5.14" -group = 'io.cloudchains' - -repositories { - mavenCentral() - gradlePluginPortal() -} - -run { - if (project.hasProperty('args')) { - args project.args.split("=") - } - standardInput = System.in -} - -shadowJar { - // mergeServiceFiles() -} - -test { - useJUnitPlatform() -} - - -graalvmNative { - binaries { - main { - buildArgs.add('--no-fallback') - imageName = 'xlite-daemon' - mainClass = 'io.cloudchains.app.App' - buildArgs.addAll([ - '--enable-native-access=ALL-UNNAMED', - '--enable-url-protocols=http,https', - '--features=com.oracle.svm.thirdparty.gson.GsonFeature', - '--features=com.oracle.svm.polyglot.groovy.GroovyIndyInterfaceFeature', - '--allow-incomplete-classpath', - '--verbose', - '--no-fallback', - '--no-server' - ]) - - // Build-time initialization - buildArgs.addAll([ - '--initialize-at-build-time=com.google.common.base.Charsets', - '--initialize-at-build-time=com.google.common.math.IntMath$1', - '--initialize-at-build-time=com.google.common.math.IntMath', - '--initialize-at-build-time=com.google.common.base.Charsets$1', - '--initialize-at-build-time=com.google.common.base.StandardCharsets', - '--initialize-at-build-time=io.netty,org.apache.commons.logging,org.slf4j', - '--initialize-at-build-time=org.slf4j.helpers.NOPLogger', - '--initialize-at-build-time=org.slf4j.helpers.NOPLoggerFactory', - '--initialize-at-build-time=org.slf4j.helpers.SubstituteLoggerFactory', - '--initialize-at-build-time=org.slf4j.helpers.Util', - '--initialize-at-build-time=org.slf4j.helpers.NOP_FallbackServiceProvider', - '--initialize-at-build-time=org.slf4j.nop.NOPServiceProvider', - '--initialize-at-build-time=org.slf4j.helpers.SubstituteServiceProvider', - '--initialize-at-build-time=org.bitcoinj.core.Utils', - '--initialize-at-build-time=org.bitcoinj.core.Sha256Hash', - '--initialize-at-build-time=org.bitcoinj.crypto.MnemonicCode', - '--initialize-at-build-time=com.google.common.io.BaseEncoding', - '--initialize-at-build-time=com.google.common.io.BaseEncoding$Base16Encoding', - '--initialize-at-build-time=com.google.common.io.BaseEncoding$Alphabet' - ]) - - // Runtime initialization - buildArgs.addAll([ - '--initialize-at-run-time=io.netty.util.internal.logging.Log4JLogger', - '--initialize-at-run-time=io.netty.handler.codec.http.HttpObjectEncoder', - '--initialize-at-run-time=io.netty.handler.codec.http2.DefaultHttp2FrameWriter', - '--initialize-at-run-time=io.netty.handler.codec.http2.Http2CodecUtil' - ]) - } - } -} - -dependencies { - implementation "org.bitcoinj:bitcoinj-core:${bitcoinjVersion}" - implementation "org.bitcoinj:orchid:1.2.1" - implementation 'net.jcip:jcip-annotations:1.0' - implementation "org.json:json:20250517" - implementation "org.apache.httpcomponents:httpclient:4.5.14" - implementation "com.google.code.gson:gson:${gsonVersion}" - implementation "commons-logging:commons-logging:1.2" - implementation "org.slf4j:slf4j-nop:${slf4jVersion}" - implementation "io.netty:netty-all:${nettyVersion}" - implementation "com.google.code.findbugs:jsr305:3.0.2" - compileOnly "org.graalvm.nativeimage:svm:21.+" - - testImplementation platform("org.junit:junit-bom:${junitVersion}") - testImplementation "org.junit.jupiter:junit-jupiter-api:${junitVersion}" - testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" - testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" - testImplementation "org.junit.platform:junit-platform-launcher" -} diff --git a/ci/WaitForShutdownTime.ps1 b/ci/WaitForShutdownTime.ps1 deleted file mode 100644 index 0d8cc58..0000000 --- a/ci/WaitForShutdownTime.ps1 +++ /dev/null @@ -1,12 +0,0 @@ -while ($true) { - $os = Get-WmiObject win32_operatingsystem -ComputerName localhost -ErrorAction SilentlyContinue - $uptime = ((get-date) - ($os.ConvertToDateTime($os.lastbootuptime))) - $totalUptime = ($uptime.Days * 1440) + ($uptime.Hours * 60) + ($uptime.Minutes) - Write-Output Uptime: $totalUptime - - if ($totalUptime -gt 50) { - Stop-Computer -ComputerName localhost - }; - - Start-Sleep 30; -} diff --git a/ci/windows-build.cmd b/ci/windows-build.cmd deleted file mode 100644 index 4b7a869..0000000 --- a/ci/windows-build.cmd +++ /dev/null @@ -1,59 +0,0 @@ -REM GRAALVM 20.1.0 IS REQUIRED - INSTALL FROM -REM https://github.com/graalvm/graalvm-ce-dev-builds/releases/download/20.1.0-dev-20200212_0349/graalvm-ce-java8-windows-amd64-20.1.0-dev.zip - -choco install -y windows-sdk-7.1 kb2519277 -choco install -y vcredist2010 - - -SET NATIVE_IMAGE=%1 -SET CC_PACKAGED_JAR=%2 -SET CC_DIR=%3 - -echo Initializing Microsoft SDK 7.1 environment -call "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\SetEnv.cmd" - -call %NATIVE_IMAGE% -jar %CC_PACKAGED_JAR% ^ - -H:Name=Cloudchains-SPV ^ - -H:Class=io.cloudchains.app.App ^ - -H:+JNI ^ - -H:+UseServiceLoaderFeature ^ - -H:ReflectionConfigurationFiles=contrib/netty-reflection.json ^ - -H:ReflectionConfigurationResources=META-INF/native-image/io.netty/transport/reflection-config.json ^ - -H:ResourceConfigurationFiles=contrib/resource-config.json ^ - -H:IncludeResources='.*/wordlist/english.txt$' ^ - -H:Log=registerResource ^ - --no-fallback ^ - --no-server ^ - -da ^ - --enable-url-protocols=http,https ^ - --initialize-at-build-time=io.netty ^ - --initialize-at-build-time=com.google.common ^ - --initialize-at-build-time=org.apache.commons.logging ^ - --initialize-at-build-time=org.slf4j.LoggerFactory ^ - --initialize-at-build-time=org.slf4j.impl.StaticLoggerBinder ^ - --initialize-at-build-time=org.slf4j.helpers.NOPLogger ^ - --initialize-at-build-time=org.slf4j.helpers.NOPLoggerFactory ^ - --initialize-at-build-time=org.slf4j.helpers.SubstituteLoggerFactory ^ - --initialize-at-build-time=org.slf4j.helpers.Util ^ - --initialize-at-build-time=org.bitcoinj.core.Utils ^ - --initialize-at-build-time=org.bitcoinj.core.Sha256Hash ^ - --initialize-at-build-time=org.bitcoinj.crypto.MnemonicCode ^ - --initialize-at-run-time=io.netty.util.internal.logging.Log4JLogger ^ - --initialize-at-run-time=io.netty.handler.codec.http.HttpObjectEncoder ^ - --initialize-at-run-time=io.netty.handler.codec.http2.DefaultHttp2FrameWriter ^ - --initialize-at-run-time=io.netty.handler.codec.http2.Http2CodecUtil ^ - --initialize-at-run-time=io.netty.buffer.PooledByteBufAllocator ^ - --initialize-at-run-time=io.netty.buffer.ByteBufAllocator ^ - --initialize-at-run-time=io.netty.buffer.ByteBufUtil ^ - --initialize-at-run-time=io.netty.buffer.AbstractReferenceCountedByteBuf ^ - --initialize-at-run-time=io.netty.handler.codec.http2.Http2CodecUtil ^ - --initialize-at-run-time=io.netty.handler.codec.http2.Http2ClientUpgradeCodec ^ - --initialize-at-run-time=io.netty.handler.codec.http2.Http2ConnectionHandler ^ - --initialize-at-run-time=io.netty.handler.codec.http2.DefaultHttp2FrameWriter ^ - --initialize-at-run-time=io.netty.util.AbstractReferenceCounted ^ - --initialize-at-run-time=io.netty.handler.codec.http.HttpObjectEncoder ^ - --initialize-at-run-time=io.netty.handler.codec.http.websocketx.WebSocket00FrameEncoder ^ - --initialize-at-run-time=io.netty.handler.codec.http.websocketx.extensions.compression.DeflateDecoder ^ - --initialize-at-run-time=io.netty.handler.ssl.util.ThreadLocalInsecureRandom ^ - --allow-incomplete-classpath ^ - --verbose diff --git a/contrib/netty-reflection.json b/contrib/netty-reflection.json deleted file mode 100644 index d2af5dc..0000000 --- a/contrib/netty-reflection.json +++ /dev/null @@ -1,98 +0,0 @@ -[ - { - "name": "io.netty.channel.socket.nio.NioServerSocketChannel", - "methods": [ - { "name": "", "parameterTypes": [] } - ] - }, - { - "name" : "io.cloudchains.app.net.CoinInstance", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "io.cloudchains.app.net.api.JSONRPCController", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "io.cloudchains.app.net.api.http.HTTPServerHandler", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "java.lang.String", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "org.bitcoinj.script.Script", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "org.bitcoinj.script.Script$ScriptType", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "org.bitcoinj.wallet.DeterministicSeed", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "com.google.common.primitives.UnsignedBytes$LexicographicalComparatorHolder$UnsafeComparator", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "org.apache.commons.logging.LogFactory", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - }, - { - "name" : "org.apache.commons.logging.impl.LogFactoryImpl", - "allDeclaredConstructors" : true, - "allPublicConstructors" : true, - "allDeclaredMethods" : true, - "allPublicMethods" : true, - "allDeclaredClasses" : true, - "allPublicClasses" : true - } -] \ No newline at end of file diff --git a/contrib/resource-config.json b/contrib/resource-config.json deleted file mode 100644 index a146d7a..0000000 --- a/contrib/resource-config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "resources":[ - {"pattern":"META-INF/services/jdk.vm.ci.hotspot.HotSpotJVMCIBackendFactory"}, - {"pattern":"META-INF/services/jdk.vm.ci.services.JVMCIServiceLocator"}, - {"pattern":"mozilla/public-suffix-list.txt"}, - {"pattern":"org/apache/http/client/version.properties"}, - {"pattern":"org/bitcoinj/crypto/mnemonic/wordlist/english.txt"}, - {"pattern":"org/slf4j/impl/StaticLoggerBinder.class"}, - {"pattern":"sun/net/idn/uidna.spp"}, - {"pattern":"sun/text/resources/unorm.icu"} - ] -} diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..db1a287 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,877 @@ +# XLite Daemon User Guide + +## Table of Contents +1. [Overview](#overview) +2. [System Requirements](#system-requirements) +3. [Installation](#installation) +4. [Configuration](#configuration) +5. [Usage](#usage) +6. [Supported Cryptocurrencies](#supported-cryptocurrencies) +7. [Security Features](#security-features) +8. [Troubleshooting](#troubleshooting) +9. [API Reference](#api-reference) +10. [Advanced Configuration](#advanced-configuration) + +## Overview + +The XLite Daemon is a multi-cryptocurrency wallet backend that provides secure wallet management, blockchain connectivity, and JSON-RPC API services for various cryptocurrencies. It serves as the backend infrastructure for the XLite wallet application. + +### Key Features +- **Multi-Currency Support**: Supports 10+ cryptocurrencies including Bitcoin, Litecoin, Dash, and more +- **Secure Wallet Management**: Industry-standard encryption with PBKDF2 key derivation +- **Address Discovery**: Automatic detection of used addresses for wallet recovery +- **JSON-RPC API**: Comprehensive API for wallet operations and blockchain queries +- **XRouter Integration**: Support for cross-chain communication (Blocknet network) +- **Native Compilation**: Optimized native binary for better performance + +## System Requirements + +### Minimum Requirements +- **Operating System**: Windows 10+, macOS 10.14+, Linux (kernel 3.10+) +- **Java**: JDK 21 or higher +- **Maven**: 3.8.6 or higher +- **Memory**: 2GB RAM minimum, 4GB recommended +- **Storage**: 500MB free space for wallet data and blockchain metadata + +### Recommended Requirements +- **Operating System**: Latest stable version of your preferred OS +- **Java**: Latest JDK 21 LTS +- **Memory**: 8GB RAM or more +- **Storage**: SSD with 1GB+ free space +- **Network**: Stable internet connection + +## Installation + +### Prerequisites +1. Install JDK 21: + ```bash + # Ubuntu/Debian + sudo apt update && sudo apt install openjdk-21-jdk + + # macOS (using Homebrew) + brew install openjdk@21 + + # Windows: Download from Oracle or Adoptium + ``` + +2. Install Maven 3.8.6+: + ```bash + # Ubuntu/Debian + sudo apt install maven + + # macOS (using Homebrew) + brew install maven + + # Verify installation + java -version + mvn -version + ``` + +### Building from Source + +1. **Clone the repository**: + ```bash + git clone https://github.com/blocknetdx/xlite-daemon + cd xlite-daemon + ``` + +2. **Make Maven wrapper executable** (Linux/macOS only): + ```bash + chmod +x mvnw + ``` + +3. **Build the project**: + ```bash + # Full build with native compilation (recommended) + ./mvnw clean package -Pnative + + # Faster build without tests (development) + ./mvnw clean package -Pnative-fast + ``` + +4. **Run the application**: + ```bash + # Using the native binary + ./target/xlite-daemon + + # Or using Maven + ./mvnw exec:java + ``` + +### Environment Variables + +Set these environment variables before running: + +```bash +# Required for wallet initialization +export WALLET_MNEMONIC="your twelve word mnemonic phrase here" +export WALLET_PASSWORD="your secure password" + +# Optional: Custom EXR endpoints (comma-separated list of backend servers) +export EXR_ENDPOINT="https://server1.example.com,https://server2.example.com" +``` + +## Configuration + +### Configuration Files Location + +The daemon stores configuration files in your system's application data directory: + +- **Windows**: `%appdata%\xlite-daemon\settings\config-*.json` +- **macOS**: `~/Library/Application Support/xlite-daemon/settings/config-*.json` +- **Linux**: `~/.config/xlite-daemon/settings/config-*.json` + +Set `XLITE_DATA_HOME` to override the platform directory above. The +daemon aborts at startup when no override is set and the platform +default is missing (Windows with unset `%AppData%`, or an empty +`user.home`). + +### Configuration Structure + +Each cryptocurrency has its own configuration file named `config-{ticker}.json`: + +```json +{ + "fee": 0.0001, + "feeFlat": true, + "rpcEnabled": true, + "rpcUsername": "xlite", + "rpcPassword": "securepassword123", + "rpcPort": 9955, + "addressCount": 25 +} +``` + +### Configuration Options + +| Setting | Type | Description | Default | +|---------|------|-------------|---------| +| `fee` | number | Transaction fee rate | 0.0001 | +| `feeFlat` | boolean | Use flat fee vs dynamic | true | +| `rpcEnabled` | boolean | Enable JSON-RPC server | false | +| `rpcUsername` | string | RPC authentication username | "" | +| `rpcPassword` | string | RPC authentication password | "" | +| `rpcPort` | number | RPC server port | -1000 (auto) | +| `addressCount` | number | Number of addresses to generate | 0 | + +## Usage + +### Starting the Daemon + +1. **Basic startup**: + ```bash + ./target/xlite-daemon + ``` + +2. **With custom arguments**: + ```bash + ./target/xlite-daemon --network=testnet --debug + ``` + +3. **As a background service**: + ```bash + # Linux/macOS + nohup ./target/xlite-daemon > daemon.log 2>&1 & + + # Windows + start /B xlite-daemon.exe > daemon.log + ``` + +### Wallet Management + +#### Creating a New Wallet + +1. **Generate a new mnemonic**: + ```bash + # The daemon will automatically generate a 12-word mnemonic on first run + # or you can provide one via WALLET_MNEMONIC environment variable + ``` + +2. **Set a secure password**: + ```bash + export WALLET_PASSWORD="your-secure-password-here" + ``` + +3. **Verify password strength**: + The daemon includes a password strength calculator that evaluates: + - Length (8+ characters required) + - Character variety (uppercase, lowercase, numbers, symbols) + - Overall security score (0-10) + +#### Importing an Existing Wallet + +```bash +# Set your existing mnemonic +export WALLET_MNEMONIC="your existing twelve word phrase here" +export WALLET_PASSWORD="your password" + +# Start the daemon +./target/xlite-daemon +``` + + +### Supported Operations + +#### Basic Wallet Operations + +```bash +# Get wallet balance +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"getinfo","params":[],"id":1}' + +# Generate new address +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"getnewaddress","params":[],"id":1}' + +# List unspent transactions (UTXOs) +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"listunspent","params":[],"id":1}' +``` + +#### Transaction Operations + +```bash +# Create raw transaction +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"createrawtransaction","params":[[{"txid":"...","vout":0}],{"address":0.001}],"id":1}' + +# Sign transaction +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"signrawtransaction","params":["rawtx"],"id":1}' + +# Broadcast transaction +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"sendrawtransaction","params":["signedtx"],"id":1}' +``` + +## Supported Cryptocurrencies + +The daemon supports the following cryptocurrencies: + +| Coin | Ticker | Network | RPC Port | Status | +|------|--------|---------|----------|---------| +| Blocknet | BLOCK | Mainnet | 41419 | ✅ Active | +| Bitcoin | BTC | Mainnet | 8332 | ✅ Active | +| Litecoin | LTC | Mainnet | 9332 | ✅ Active | +| Dash | DASH | Mainnet | 9998 | ✅ Active | +| Dogecoin | DOGE | Mainnet | 22555 | ✅ Active | +| Syscoin | SYS | Mainnet | 8370 | ✅ Active | +| PIVX | PIVX | Mainnet | 9951 | ✅ Active | +| Unobtanium | UNO | Mainnet | 65111 | ✅ Active | + +### Network Configuration + +Each coin has specific network parameters: +- **Mainnet**: Production blockchain networks +- **Testnet**: Testing and development networks +- **RPC Ports**: Default ports for JSON-RPC communication + +## Security Features + +### Wallet Encryption + +The daemon uses industry-standard security practices: + +1. **PBKDF2 Key Derivation**: SHA-256 with 100,000 iterations +2. **AES Encryption**: 256-bit encryption for wallet data +3. **Secure Random Generation**: Cryptographically strong random number generation +4. **Memory Protection**: Sensitive data cleared from memory after use + +### Legacy Wallet Migration + +The daemon automatically migrates legacy wallets: + +1. **Detection**: Identifies SHA-1 based legacy wallets +2. **Backup**: Creates backup before migration +3. **Upgrade**: Migrates to SHA-256 with improved security +4. **Validation**: Verifies migration success before cleanup + +### Password Security + +The daemon includes password strength validation: + +```bash +# Password scoring system: +# 8-9 characters: 1 point +# 10+ characters: 2 points +# Contains digit: +2 points +# Contains lowercase: +2 points +# Contains uppercase: +2 points +# Contains special character: +2 points +# Maximum score: 10 points +``` + +### Address Security + +1. **HD Wallet Support**: Hierarchical Deterministic wallet generation +2. **Address Gap Limit**: Prevents address exhaustion attacks +3. **Forward Address Generation**: Pre-generates addresses for performance +4. **Address Discovery**: Automatically finds used addresses for recovery + +## Troubleshooting + +### Common Issues + +#### 1. Port Already in Use + +**Problem**: "Address already in use" error on startup + +**Solution**: +```bash +# Check which process is using the port +lsof -i :9955 # Linux/macOS +netstat -ano | findstr :9955 # Windows + +# Kill the process or change the port in config +``` + +#### 2. Wallet Not Found + +**Problem**: "Wallet not found on disk" error + +**Solution**: +```bash +# Check if wallet file exists +ls ~/.config/xlite-daemon/key.dat + +# Verify permissions +chmod 600 ~/.config/xlite-daemon/key.dat +``` + +#### 3. Network Connection Issues + +**Problem**: Cannot connect to blockchain networks + +**Solution**: +```bash +# Check network connectivity +ping blockexplorer.com + +# Verify firewall settings +# Ensure outbound connections are allowed +``` + +#### 4. Memory Issues + +**Problem**: OutOfMemoryError or slow performance + +**Solution**: +```bash +# Increase Java heap size +export JAVA_OPTS="-Xmx2g -Xms1g" + +# Or modify the startup script +java -Xmx2g -jar xlite-daemon.jar +``` + +### Log Analysis + +#### Log File Locations + +- **Error logs**: `~/.config/xlite-daemon/error-YYYY-MM-DD.log` +- **Application logs**: Console output (configurable) + +#### Common Log Patterns + +```bash +# Wallet initialization +[wallet] Initializing wallet for BTC + +# Network connection +[peer] Connecting to network... + +# RPC requests +[rpc] Received JSON-RPC request: getinfo + +# Address discovery +[discovery] Processing batch starting at index 100 +``` + +### Debug Mode + +Enable debug logging: + +```bash +# Set logging level +export LOG_LEVEL=DEBUG + +# Or modify logging.properties +handlers=java.util.logging.ConsoleHandler +.level=FINE +``` + +### Performance Optimization + +#### Address Discovery Optimization + +1. **Batch Size**: Adjust batch size for your network +2. **Gap Limit**: Modify gap limit for faster discovery +3. **Timeout Settings**: Configure discovery timeouts + +#### Memory Management + +1. **Heap Size**: Allocate sufficient memory +2. **GC Tuning**: Optimize garbage collection +3. **Connection Pooling**: Reuse network connections + +### Recovery Procedures + +#### Wallet Recovery + +1. **From Mnemonic**: + ```bash + export WALLET_MNEMONIC="your twelve word phrase" + ./target/xlite-daemon + ``` + +2. **From Backup**: + ```bash + # Restore from backup directory + cp ~/.config/xlite-daemon/backups/key-backup-*.dat ~/.config/xlite-daemon/key.dat + ``` + +#### Configuration Recovery + +1. **Reset Configuration**: + ```bash + # Remove config files to reset + rm ~/.config/xlite-daemon/settings/config-*.json + ``` + +2. **Rebuild from Source**: + ```bash + ./mvnw clean package -Pnative + ``` + +## API Reference + +### JSON-RPC API + +The daemon provides a comprehensive JSON-RPC API for wallet and blockchain operations. + +#### Authentication + +All RPC requests require authentication: + +```bash +# Set credentials in config file or environment +rpcUsername=xlite +rpcPassword=yourpassword +``` + +#### Blockchain Operations + +##### getblockchaininfo +Get blockchain information + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"getblockchaininfo","params":[],"id":1}' +``` + +**Response**: +```json +{ + "result": { + "chain": "main", + "blocks": 700000, + "headers": 700000, + "bestblockhash": "00000000000000000007bd1b11320e37172c4467554f7a87bf769874e65e361d", + "difficulty": 21768156514421.46, + "mediantime": 1640995200 + } +} +``` + +##### getblockhash +Get block hash by height + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"getblockhash","params":[700000],"id":1}' +``` + +##### getblock +Get block information + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"getblock","params":["blockhash"],"id":1}' +``` + +#### Wallet Operations + +##### getinfo +Get wallet information + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"getinfo","params":[],"id":1}' +``` + +**Response**: +```json +{ + "result": { + "version": "0.5.15", + "protocolversion": 70015, + "walletversion": 169900, + "balance": 1.50000000, + "blocks": 700000, + "timeoffset": 0, + "connections": 8, + "proxy": "", + "difficulty": 21768156514421.46, + "testnet": false, + "keypoololdest": 1640995200, + "keypoolsize": 1000, + "paytxfee": 0.00001000, + "relayfee": 0.00001000, + "errors": "" + } +} +``` + +##### getnewaddress +Generate new wallet address + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"getnewaddress","params":[],"id":1}' +``` + +**Response**: +```json +{ + "result": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" +} +``` + +##### listunspent +List unspent transaction outputs + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"listunspent","params":[],"id":1}' +``` + +**Response**: +```json +{ + "result": [ + { + "txid": "a1b2c3d4...", + "vout": 0, + "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "scriptPubKey": "001472749d6b6e...", + "amount": 0.50000000, + "confirmations": 100, + "spendable": true + } + ] +} +``` + +##### gettransaction +Get transaction details + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"gettransaction","params":["txid"],"id":1}' +``` + +#### Raw Transaction Operations + +##### createrawtransaction +Create raw transaction + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"createrawtransaction","params":[[{"txid":"a1b2c3d4...","vout":0}],[{"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh":0.1}]],"id":1}' +``` + +##### decoderawtransaction +Decode raw transaction + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"decoderawtransaction","params":["rawtx"],"id":1}' +``` + +##### signrawtransaction +Sign raw transaction + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"signrawtransaction","params":["rawtx"],"id":1}' +``` + +##### sendrawtransaction +Broadcast transaction + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"sendrawtransaction","params":["signedtx"],"id":1}' +``` + +#### Utility Operations + +##### dumpprivkey +Export private key + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"dumpprivkey","params":["address"],"id":1}' +``` + +##### importprivkey +Import private key + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"importprivkey","params":["privatekey"],"id":1}' +``` + +##### signmessage +Sign message with address + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"signmessage","params":["address","message"],"id":1}' +``` + +##### verifymessage +Verify signed message + +```bash +curl -X POST http://localhost:9955 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"verifymessage","params":["address","signature","message"],"id":1}' +``` + +### Error Handling + +#### Common Error Codes + +| Code | Message | Description | +|------|---------|-------------| +| -1 | "Bad password" | Incorrect wallet password | +| -2 | "Unsupported coin" | Coin not supported | +| -3 | "Bad mnemonic" | Invalid mnemonic phrase | +| -4 | "Change password failed" | Password change failed | +| -5 | "Wallet not found" | No wallet file exists | + +#### Error Response Format + +```json +{ + "error": { + "code": -1, + "message": "Bad password" + }, + "id": 1 +} +``` + +## Advanced Configuration + +### Custom RPC Ports + +Configure custom RPC ports for each coin: + +```json +{ + "rpcPort": 9955, + "rpcEnabled": true, + "rpcUsername": "xlite", + "rpcPassword": "securepassword" +} +``` + +### Network Configuration + +#### Proxy Settings + +Configure proxy for network connections: + +```bash +# Set proxy environment variables +export http_proxy="http://proxy.company.com:8080" +export https_proxy="http://proxy.company.com:8080" +``` + +#### Custom Nodes + +Configure custom blockchain nodes: + +```json +{ + "customNodes": [ + "node1.example.com:8333", + "node2.example.com:8333" + ] +} +``` + +### Performance Tuning + +#### JVM Parameters + +Optimize Java Virtual Machine settings: + +```bash +# High-performance settings +export JAVA_OPTS="-Xmx4g -Xms2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200" +``` + +#### Network Optimization + +```bash +# Increase connection limits +export MAX_CONNECTIONS=50 + +# Optimize network buffers +export NETWORK_BUFFER_SIZE=65536 +``` + +### Security Hardening + +#### Firewall Configuration + +Allow necessary ports: + +```bash +# Linux (iptables) +sudo iptables -A INPUT -p tcp --dport 9955 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 41419 -j ACCEPT + +# Windows (PowerShell) +New-NetFirewallRule -DisplayName "XLite Daemon" -Direction Inbound -Protocol TCP -LocalPort 9955 -Action Allow +``` + +#### File Permissions + +Secure wallet and configuration files: + +```bash +# Set restrictive permissions +chmod 600 ~/.config/xlite-daemon/key.dat +chmod 600 ~/.config/xlite-daemon/settings/config-*.json +chmod 700 ~/.config/xlite-daemon/ +``` + +### Monitoring and Logging + +#### Log Rotation + +Configure automatic log rotation: + +```bash +# Create logrotate configuration +sudo nano /etc/logrotate.d/xlite-daemon + +# Add configuration +/home/user/.config/xlite-daemon/error-*.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty +} +``` + +#### Health Monitoring + +Monitor daemon health: + +```bash +# Check process status +ps aux | grep xlite-daemon + +# Monitor logs in real-time +tail -f ~/.config/xlite-daemon/error-*.log + +# Check network connections +netstat -tulpn | grep xlite-daemon +``` + +### Backup and Recovery + +#### Automated Backups + +Create automated backup script: + +```bash +#!/bin/bash +# backup-wallet.sh + +BACKUP_DIR="/backup/xlite-daemon" +DATE=$(date +%Y%m%d_%H%M%S) + +mkdir -p "$BACKUP_DIR" + +# Backup wallet file +cp ~/.config/xlite-daemon/key.dat "$BACKUP_DIR/key-$DATE.dat" + +# Backup configuration +cp ~/.config/xlite-daemon/settings/config-*.json "$BACKUP_DIR/" + +# Compress backup +tar -czf "$BACKUP_DIR/backup-$DATE.tar.gz" -C "$BACKUP_DIR" . + +# Clean old backups (keep 30 days) +find "$BACKUP_DIR" -name "*.tar.gz" -mtime +30 -delete + +echo "Backup completed: $BACKUP_DIR/backup-$DATE.tar.gz" +``` + +#### Recovery Script + +Create recovery script: + +```bash +#!/bin/bash +# restore-wallet.sh + +BACKUP_FILE="$1" + +if [ -z "$BACKUP_FILE" ]; then + echo "Usage: $0 " + exit 1 +fi + +# Stop daemon +pkill xlite-daemon + +# Backup current data +mv ~/.config/xlite-daemon ~/.config/xlite-daemon.backup + +# Extract backup +tar -xzf "$BACKUP_FILE" -C ~/.config/ + +echo "Wallet restored from $BACKUP_FILE" +echo "Start the daemon to verify recovery" +``` + +This comprehensive user guide provides everything needed to install, configure, and use the XLite Daemon effectively. For additional support, refer to the troubleshooting section or consult the API reference for detailed technical information. \ No newline at end of file diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index d7377e6..0000000 --- a/gradle.properties +++ /dev/null @@ -1,9 +0,0 @@ -# Gradle performance improvements -org.gradle.parallel=true -org.gradle.caching=true -org.gradle.configureondemand=true -org.gradle.daemon=true -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC - -# Java compatibility -kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 41d9927..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index cd4b7aa..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip -networkTimeout=10000 -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index 1b6c787..0000000 --- a/gradlew +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 107acd3..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/nativeImageWindows.bat b/nativeImageWindows.bat index c2cb081..936f3f9 100644 --- a/nativeImageWindows.bat +++ b/nativeImageWindows.bat @@ -135,10 +135,23 @@ SET "PATH=%SDK_BIN_PATH%;%PATH%" SET "INCLUDE=%SDK_INCLUDE_PATH%;%INCLUDE%" SET "LIB=%SDK_LIB_PATH%;%LIB%" -echo Building XLite Daemon with nativeCompile... +echo Building XLite Daemon with Maven native profile... + +REM Check if Maven wrapper exists, if not use mvn directly +if exist mvnw.cmd ( + echo Using Maven wrapper (mvnw.cmd)... + set "MAVEN_CMD=mvnw.cmd" +) else if exist mvnw.bat ( + echo Using Maven wrapper (mvnw.bat)... + set "MAVEN_CMD=mvnw.bat" +) else ( + echo Maven wrapper not found, using system mvn command... + set "MAVEN_CMD=mvn" +) -REM Build the project using Gradle nativeCompile task -gradlew.bat clean nativeCompile --info +REM Build the project using Maven with native profile +echo Running: %MAVEN_CMD% clean compile exec:java -Pnative -DskipTests +%MAVEN_CMD% clean compile exec:java -Pnative -DskipTests if %ERRORLEVEL% NEQ 0 ( echo Build failed! @@ -146,7 +159,7 @@ if %ERRORLEVEL% NEQ 0 ( ) REM Additional check for build output -if not exist build\native\nativeCompile\xlite-daemon.exe ( +if not exist target\xlite-daemon.exe ( echo Build completed but native image not found at expected location exit /b 1 ) @@ -154,9 +167,9 @@ if not exist build\native\nativeCompile\xlite-daemon.exe ( echo Build completed successfully! REM Rename the output file -if exist build\native\nativeCompile\xlite-daemon.exe ( - ren build\native\nativeCompile\xlite-daemon.exe xlite-daemon-win64.exe - echo Native image created: build\native\nativeCompile\xlite-daemon-win64.exe +if exist target\xlite-daemon.exe ( + ren target\xlite-daemon.exe xlite-daemon-win64.exe + echo Native image created: target\xlite-daemon-win64.exe ) else ( echo Error: Native image not found at expected location exit /b 1 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9d51d0c --- /dev/null +++ b/pom.xml @@ -0,0 +1,528 @@ + + + 4.0.0 + + + io.xlite.daemon + xlite-daemon + 0.5.15 + jar + + XLite Daemon + XLite Daemon - Multi-cryptocurrency wallet daemon + https://github.com/blocknetdx/xlite-daemon + + + + UTF-8 + 21 + 21 + 21 + 21 + + + compatibility + + + 0.15.10 + 4.2.7.Final + 5.11.3 + 2.13.2 + + + 0.11.3 + 3.13.0 + 3.6.0 + 3.5.0 + 6.25.0 + + + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + + org.bitcoinj + bitcoinj-core + ${bitcoinj.version} + + + commons-logging + commons-logging + + + org.apache.httpcomponents + httpclient + + + org.apache.httpcomponents + httpcore + + + + org.slf4j + slf4j-api + + + + + + + com.google.guava + guava + 28.2-android + + + + + org.bitcoinj + orchid + 1.2.1 + + + + + com.google.code.gson + gson + ${gson.version} + + + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + + + org.slf4j + slf4j-api + 1.7.36 + + + + + org.slf4j + slf4j-nop + 1.7.36 + + + + + org.json + json + 20250517 + + + + + io.github.cdimascio + java-dotenv + 5.2.2 + + + + + io.netty + netty-common + ${netty.version} + + + io.netty + netty-buffer + ${netty.version} + + + io.netty + netty-transport + ${netty.version} + + + io.netty + netty-handler + ${netty.version} + + + io.netty + netty-codec-http + ${netty.version} + + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.mockito + mockito-core + 5.15.2 + test + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + **/*Test*.java + + -javaagent:${settings.localRepository}/net/bytebuddy/byte-buddy-agent/1.15.11/byte-buddy-agent-1.15.11.jar -Djava.util.logging.level=WARNING + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${java.version} + ${java.version} + ${java.version} + + -Xlint:unchecked + -Xlint:deprecation + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce-versions + + enforce + + + + + 21 + + + 3.8.6 + + + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven.shade.plugin.version} + + + + shade + + package + + true + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + META-INF/DEPENDENCIES + META-INF/LICENSE.txt + META-INF/NOTICE.txt + META-INF/MANIFEST.MF + + META-INF/versions/*/module-info.class + META-INF/versions/9/module-info + META-INF/versions/11/module-info + + META-INF/io.netty.versions.properties + + + + + + simplelogger.properties + + + + + + + + org.bitcoinj:bitcoinj-core + org.bouncycastle:bcprov-jdk15to18 + com.google.protobuf:protobuf-java + com.google.guava:guava + net.jcip:jcip-annotations + com.lambdaworks:scrypt + com.squareup.okhttp:okhttp + com.squareup.okio:okio + org.bitcoinj:orchid + com.google.code.gson:gson + com.google.errorprone:error_prone_annotations + org.apache.httpcomponents:httpclient + commons-logging:commons-logging + commons-codec:commons-codec + org.json:json + io.netty:netty-common + io.netty:netty-buffer + io.netty:netty-transport + io.netty:netty-resolver + io.netty:netty-handler + io.netty:netty-transport-native-unix-common + io.netty:netty-codec-base + io.netty:netty-codec-http + io.netty:netty-codec-compression + com.google.code.findbugs:jsr305 + io.github.cdimascio:java-dotenv + + + + org.apache.httpcomponents:httpcore + org.slf4j:slf4j-api + + + + + + + + + + org.graalvm.buildtools + native-maven-plugin + ${native.maven.plugin.version} + + + io.xlite.daemon.app.App + + + xlite-daemon + + + false + true + + + + --enable-url-protocols=http,https + --enable-native-access=ALL-UNNAMED + --strict-image-heap + -march=${native.march} + -H:+ReportExceptionStackTraces + + + --initialize-at-run-time=io.netty + --initialize-at-run-time=io.netty.util.internal + --initialize-at-run-time=io.netty.channel + --initialize-at-run-time=io.netty.channel.nio + --initialize-at-run-time=io.netty.buffer + --initialize-at-run-time=io.netty.handler + --initialize-at-run-time=io.netty.bootstrap + --initialize-at-run-time=org.apache.httpcomponents + --initialize-at-run-time=org.json + --initialize-at-run-time=org.slf4j + --initialize-at-run-time=org.slf4j.impl + + --initialize-at-run-time=io.xlite.daemon.app.App + --initialize-at-run-time=io.xlite.daemon.app.net.api.JSONRPCController + + + --initialize-at-build-time=com.google.common.base.Charsets + --initialize-at-build-time=com.google.common.base.CharMatcher + --initialize-at-build-time=com.google.common.base.CharMatcher$8 + --initialize-at-build-time=com.google.common.math.IntMath + --initialize-at-build-time=com.google.common.base.StandardCharsets + --initialize-at-build-time=com.google.common.base.Joiner + --initialize-at-build-time=com.google.common.io.BaseEncoding + --initialize-at-build-time=com.google.common.io.BaseEncoding$StandardBaseEncoding + --initialize-at-build-time=com.google.common.io.BaseEncoding$Alphabet + --initialize-at-build-time=com.google.common.io.BaseEncoding$Base16Encoding + --initialize-at-build-time=org.apache.commons.logging + --initialize-at-build-time=org.slf4j.helpers.NOPLogger + --initialize-at-build-time=org.bitcoinj.core.Utils + --initialize-at-build-time=org.bitcoinj.core.Utils$Runtime + --initialize-at-build-time=org.bitcoinj.core.Sha256Hash + --initialize-at-build-time=org.bitcoinj.crypto.MnemonicCode + --initialize-at-build-time=io.xlite.daemon.app.util + --initialize-at-build-time=io.xlite.daemon.app.crypto + --initialize-at-build-time=io.xlite.daemon.app.console + --initialize-at-build-time=io.xlite.daemon.app.net.api + --initialize-at-build-time=io.xlite.daemon.app.net.protocols + --initialize-at-build-time=io.xlite.daemon.app.net.xrouter + --initialize-at-build-time=io.xlite.daemon.app.net.api.http + + + + --report-unsupported-elements-at-runtime + + + + + build-native + + compile-no-fork + + package + + + + + + + org.openrewrite.maven + rewrite-maven-plugin + ${rewrite.maven.plugin.version} + + + + + org.openrewrite.maven.cleanup.DependencyManagementDependencyRequiresVersion + org.openrewrite.maven.cleanup.ExplicitPluginGroupId + org.openrewrite.maven.cleanup.ExplicitPluginVersion + org.openrewrite.maven.cleanup.PrefixlessExpressions + org.openrewrite.maven.RemoveDuplicateDependencies + org.openrewrite.maven.RemoveDuplicatePluginDeclarations + org.openrewrite.maven.RemoveRedundantDependencyVersions + org.openrewrite.maven.RemoveRedundantProperties + org.openrewrite.maven.RemoveUnusedProperties + org.openrewrite.maven.OrderPomElements + org.openrewrite.maven.ModernizeObsoletePoms + org.openrewrite.maven.BestPractices + + + org.openrewrite.java.RemoveUnusedImports + org.openrewrite.java.format.AutoFormat + org.openrewrite.java.OrderImports + org.openrewrite.java.format.RemoveTrailingWhitespace + org.openrewrite.java.RemoveObjectsIsNull + org.openrewrite.java.ShortenFullyQualifiedTypeReferences + org.openrewrite.java.SimplifySingleElementAnnotation + + + + + + + + + true + + + + + + org.codehaus.mojo + exec-maven-plugin + ${exec.maven.plugin.version} + + io.xlite.daemon.app.App + true + + + java.util.logging.config.file + logging.properties + + + + + + + + + + + + native + + + + org.graalvm.buildtools + native-maven-plugin + 0.11.3 + + false + + + + + + + + + native-fast + + true + + + + + + rewrite + + + + org.openrewrite.maven + rewrite-maven-plugin + ${rewrite.maven.plugin.version} + + + + cleanup-code + process-classes + + run + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/App.java b/src/main/java/io/cloudchains/app/App.java deleted file mode 100644 index 6bc7315..0000000 --- a/src/main/java/io/cloudchains/app/App.java +++ /dev/null @@ -1,141 +0,0 @@ -package io.cloudchains.app; - -import io.cloudchains.app.console.*; -import io.cloudchains.app.net.api.JSONRPCController; -import io.cloudchains.app.net.api.JSONRPCMasterServer; -import io.cloudchains.app.net.api.http.client.HTTPClient; -import io.cloudchains.app.net.api.http.client.EXRWrapper; -import io.cloudchains.app.net.api.http.client.EXRServerPool; -import io.cloudchains.app.util.CCLogger; - -import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.time.format.DateTimeFormatter; -import java.util.Date; -import java.util.logging.*; - -public class App { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private static final boolean isLoggingEnabled = false; - // DEBUG ENDPOINT - public static String BASE_URL = "https://xliterevp.mywire.org/"; - // "http://xl-dae-prox.airdns.org:42111/"; - // DEBUG ENDPOINT - public static String EXR_ENDPOINT = null; - public static EXRServerPool exrServerPool = null; - public static HTTPClient feeUpdateHttpClient = new HTTPClient(2); - public static HTTPClient heightUpdateHttpClient = new HTTPClient(2); - public static JSONRPCMasterServer masterRPC = JSONRPCController.getMasterServer(); - public static ConsoleMenu console = null; - - static { - // Check for EXR_ENDPOINT environment variable - String exrEndpoint = System.getenv("EXR_ENDPOINT"); - if (exrEndpoint != null && !exrEndpoint.isEmpty()) { - EXR_ENDPOINT = exrEndpoint; - exrServerPool = new EXRServerPool(EXR_ENDPOINT); - LOGGER.log(Level.INFO, "[app] EXR mode enabled with " + exrServerPool.getServerCount() + " servers: " + EXR_ENDPOINT); - } - } - - public static void main(String[] args) { - CCLogger.setLogging(isLoggingEnabled); - LOGGER.setLevel(Level.INFO); - LOGGER.setUseParentHandlers(false); - - Runtime.getRuntime().addShutdownHook(new Thread(App::shutdown)); - - try { - String userHomeDir; - String OS = (System.getProperty("os.name")).toLowerCase(); - - if (OS.contains("win")) { - userHomeDir = System.getenv("AppData"); - } else if (OS.contains("nix") || OS.contains("nux") || OS.contains("aix")) { - userHomeDir = System.getProperty("user.home") + File.separator + ".config"; - } else if (OS.contains("mac")) { - userHomeDir = System.getProperty("user.home") + File.separator + "Library" + File.separator + "Application Support"; - } else { - userHomeDir = System.getProperty("user.home") + File.separator + ".config"; - } - - DateTimeFormatter timeStampPattern = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - Handler fileHandler = new FileHandler( - userHomeDir + - File.separator + - "CloudChains" + - File.separator + - "error-" + - timeStampPattern.format(java.time.LocalDateTime.now()) + - ".log", - true - ); - - fileHandler.setFormatter(new SimpleFormatter() { - private static final String format = "[%1$tF %1$tT] [%2$-7s] %3$s %n"; - - @Override - public synchronized String format(LogRecord lr) { - return String.format(format, - new Date(lr.getMillis()), - lr.getLevel().getLocalizedName(), - lr.getMessage() - ); - } - }); - fileHandler.setLevel(Level.INFO); - - LOGGER.addHandler(fileHandler); - - } catch (IOException e) { - // TODO Auto-generated catch block - } - - ConsoleHandler consoleHandler = new ConsoleHandler (){ - @Override - protected synchronized void setOutputStream(OutputStream out) throws SecurityException { - super.setOutputStream(System.out); - } - }; - consoleHandler.setLevel(Level.FINE); - - LOGGER.addHandler(consoleHandler); - - console = new ConsoleMenu(args); - console.init(); - } - - public static void shutdown() { - if (masterRPC.isAlive()) { - System.out.println("Shutting down..."); - } - - if (feeUpdateHttpClient != null) { - feeUpdateHttpClient.close(); - } - - if (heightUpdateHttpClient != null) { - heightUpdateHttpClient.close(); - } - - if (exrServerPool != null) { - exrServerPool.close(); - } - - if (masterRPC != null) { - masterRPC.deinit(); - } - - if (console != null) { - console.deinit(); - } - - for (Handler handler : LOGGER.getHandlers()) { - LOGGER.removeHandler(handler); - handler.close(); - } - } -} diff --git a/src/main/java/io/cloudchains/app/console/ArgMenu.java b/src/main/java/io/cloudchains/app/console/ArgMenu.java deleted file mode 100644 index 3657a2e..0000000 --- a/src/main/java/io/cloudchains/app/console/ArgMenu.java +++ /dev/null @@ -1,98 +0,0 @@ -package io.cloudchains.app.console; - -import io.cloudchains.app.crypto.KeyHandler; -import io.cloudchains.app.crypto.LoginUtils; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; - -public class ArgMenu { - private String[] arguments; - - public ArgMenu(String[] args) { - this.arguments = args; - } - - public void init() { - int selection = 2; - String password = ""; - - System.out.println("-------------------------"); - System.out.println("Help: "); - System.out.println("Create new wallet: --new-wallet (password)"); - System.out.println("Decrypt wallet: --decrypt-wallet (password)"); - - if (arguments.length == 0) { - System.out.println("No arguments given."); - System.exit(0); - } else { - if (arguments.length == 1) { - password = arguments[0]; - } else if (arguments.length == 2 && arguments[0].equals("--new-wallet")) { - selection = 1; - password = arguments[1]; - } else if (arguments.length == 2 && arguments[0].equals("--decrypt-wallet")) { - password = arguments[1]; - } - } - - String entropy = null; - - switch (selection) { - case 1: { - if (KeyHandler.existsBaseECKeyFromLocal()) { - System.out.println("Key already exists"); - return; - } - - int strength = KeyHandler.calculatePasswordStrength(password); - - if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { - System.out.println("Bad password."); - return; - } - - entropy = LoginUtils.loginToEntropy(password); - break; - } - case 2: { - int strength = KeyHandler.calculatePasswordStrength(password); - - if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { - System.out.println("Bad password."); - return; - } - - entropy = LoginUtils.loginToEntropy(password); - break; - } - case 3: { - System.out.println("Exiting..."); - System.exit(0); - } - break; - default: - throw new IllegalStateException("Unexpected value: " + selection); - } - - if (entropy == null) - return; - - completeLogin(entropy, null); - } - - private void completeLogin(String entropy, String userMnemonic) { - CoinInstance.CoinError coinError = CoinInstance.getInstance(CoinTicker.BLOCKNET).init(entropy, userMnemonic, false); - if (coinError != null) { - System.out.println("[master] Error(" + coinError.getCode().name() + "): " + coinError.getMessage()); - System.exit(0); - } - - for (CoinTicker cointicker : CoinTicker.coins()) { - if (cointicker == CoinTicker.BLOCKNET || cointicker == CoinTicker.BLOCKNET_TESTNET5 || cointicker == CoinTicker.BITCOIN) - continue; - coinError = CoinInstance.getInstance(cointicker).init(entropy, userMnemonic, false); - if (coinError != null) - System.out.println("[" + cointicker.name() + "] Error(" + coinError.getCode().name() + "): " + coinError.getMessage()); - } - } -} diff --git a/src/main/java/io/cloudchains/app/console/ConsoleMenu.java b/src/main/java/io/cloudchains/app/console/ConsoleMenu.java deleted file mode 100644 index 2b33c31..0000000 --- a/src/main/java/io/cloudchains/app/console/ConsoleMenu.java +++ /dev/null @@ -1,409 +0,0 @@ -package io.cloudchains.app.console; - -import io.cloudchains.app.App; -import io.cloudchains.app.Version; -import io.cloudchains.app.crypto.KeyHandler; -import io.cloudchains.app.crypto.LoginUtils; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.api.http.client.EXRWrapper; -import io.cloudchains.app.net.api.http.client.EXRServerPool; -import io.cloudchains.app.net.protocols.blocknet.BlocknetNetworkParameters; -import io.cloudchains.app.util.ConfigHelper; -import io.cloudchains.app.util.background.BackgroundTimerThread; -import org.bitcoinj.core.Context; - -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Scanner; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class ConsoleMenu { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - private String[] arguments; - private BackgroundTimerThread backgroundTimerThread = null; - private boolean xliteRPC = false; - - public ConsoleMenu(String[] args) { - this.arguments = args; - LOGGER.setLevel(Level.INFO); - } - - public void logBadPassword(String msg) { - if (msg == null || msg.isEmpty()) - msg = "Bad password"; - LOGGER.log(Level.INFO, "[master] Error(" + CoinInstance.CoinError.CoinErrorCode.BADPASSWORD.name() + "): " + msg); - } - - public void logBadMnemonic() { - LOGGER.log(Level.INFO, "[master] Error(" + CoinInstance.CoinError.CoinErrorCode.BADMNEMONIC.name() + "): Bad mnemonic"); - } - - public void logBadChangePass(String msg) { - LOGGER.log(Level.INFO, "[master] Error(" + CoinInstance.CoinError.CoinErrorCode.CHANGEPASSWORDFAILED.name() + "): " + msg); - } - - public void init() { - if (System.getenv("WALLET_MNEMONIC") != null) { - String mnemonicImport = System.getenv("WALLET_MNEMONIC"); - if (mnemonicImport == null) { - LOGGER.log(Level.INFO, "Bad mnemonic."); - return; - } - - completeLogin(mnemonicImport, null, true); - return; - } else if (System.getenv("WALLET_PASSWORD") != null) { - String password = System.getenv("WALLET_PASSWORD"); - if (password == null) { - LOGGER.log(Level.INFO, "Bad password."); - return; - } - - int strength = KeyHandler.calculatePasswordStrength(password); - - if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { - LOGGER.log(Level.INFO, "Bad password."); - return; - } - - completeLogin(LoginUtils.loginToEntropy(password), null, false); - } - - int selection; - String newWalletStr = ""; - Scanner input = new Scanner(System.in); - - if (KeyHandler.existsBaseECKeyFromLocal()) { - newWalletStr = "- Disabled. Wallet already exists."; - } - - if (arguments.length > 0) { - for (int i = 0; i < arguments.length; i++) { - String argument = arguments[i]; - - switch (argument) { - case "--enablerpcandconfigure": - autoGenerateRPCConfig(); - break; - case "--development-endpoint": { - // sample endpoint url: "https://utils.blocknet.org/" - if (i + 1 < arguments.length) { - // Check if there is another argument after "--development-endpoint" - String customEndpoint = arguments[i + 1]; - App.BASE_URL = customEndpoint; - i++; // Increment i to skip the next argument (custom endpoint) - } else { - LOGGER.log(Level.WARNING, "Missing custom endpoint after '--development-endpoint'"); - } - break; - } - case "--exr-endpoint": { - if (i + 1 < arguments.length) { - // Check if there is another argument after "--exr-endpoint" - String exrEndpoint = arguments[i + 1]; - App.EXR_ENDPOINT = exrEndpoint; - App.exrServerPool = new EXRServerPool(App.EXR_ENDPOINT); - LOGGER.log(Level.INFO, "[console] EXR mode enabled with " + App.exrServerPool.getServerCount() + " servers: " + App.EXR_ENDPOINT); - // Start capability probing in background - new Thread(() -> { - try { - Thread.sleep(1000); // Wait 1 second before starting probe - App.exrServerPool.probeAllCapabilities(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }, "EXR-Capability-Prober").start(); - i++; // Increment i to skip the next argument (EXR endpoint) - } else { - LOGGER.log(Level.WARNING, "Missing EXR endpoint after '--exr-endpoint'"); - } - break; - } - case "--version": - LOGGER.log(Level.INFO, Version.CLIENT_VERSION); - return; - case "--createdefaultwallet": { - if (KeyHandler.existsBaseECKeyFromLocal()) { - LOGGER.log(Level.INFO, "Wallet already exists"); - return; - } - - String password = readPassword(input, arguments, i + 1, ""); - int strength = KeyHandler.calculatePasswordStrength(password); - if (strength < 9) { - logBadPassword(null); - return; - } - - String entropy = LoginUtils.loginToEntropy(password); - completeLogin(entropy, null, false); - - return; - } - case "--createwalletmnemonic": { - if (KeyHandler.existsBaseECKeyFromLocal()) { - LOGGER.log(Level.INFO, "Wallet already exists"); - return; - } - - String password = readPassword(input, arguments, i + 1, ""); - String mnemonic = readPassword(input, arguments, i + 2, "Mnemonic:\n").trim(); - int strength = KeyHandler.calculatePasswordStrength(password); - if (strength < 9) { - logBadPassword(null); - return; - } - if (mnemonic.isEmpty()) { - logBadMnemonic(); - return; - } - - String entropy = LoginUtils.loginToEntropy(password); - completeLogin(entropy, mnemonic, false); - return; - } - case "--xliterpc": { - // Increment RPC port by 1 - xliteRPC = true; - - break; - } - case "--password": { - // If password is provided as an arg then use it, otherwise ask for - // password via stdin. Don't mistake another cmd option as the - // password. - String password = readPassword(input, arguments, i + 1, ""); - int strength = KeyHandler.calculatePasswordStrength(password); - - if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { - LOGGER.log(Level.INFO, "Bad password."); - return; - } - - String entropy = LoginUtils.loginToEntropy(password); - completeLogin(entropy, null, false); - - return; - } - case "--getmnemonic": { - String password = readPassword(input, arguments, i + 1, ""); - - if (!KeyHandler.existsBaseECKeyFromLocal()) { - LOGGER.log(Level.INFO, "No wallet found."); - return; - } - - String entropy = LoginUtils.loginToEntropy(password); - String mnemonic = CoinInstance.getMnemonicForPw(entropy); - System.out.println(mnemonic); - return; - } - case "--changepassword": { - if (!KeyHandler.existsBaseECKeyFromLocal()) { - logBadChangePass("Wallet not found"); - return; - } - - String currentPassword = readPassword(input, arguments, i + 1, ""); - String newPassword = readPassword(input, arguments, i + 2, ""); - if (currentPassword.isEmpty() || newPassword.isEmpty()) { - LOGGER.log(Level.INFO, "Password cannot be empty"); - return; - } - if (currentPassword.equals(newPassword)) { - LOGGER.log(Level.INFO, "New password must be different from old password"); - return; - } - - // Check new password strength - int strength = KeyHandler.calculatePasswordStrength(newPassword); - if (strength < 9) { - LOGGER.log(Level.INFO, "Unable to change the password: New password is not strong enough"); - return; - } - - CoinInstance.CoinError err = CoinInstance.changePassword(LoginUtils.loginToEntropy(currentPassword), - LoginUtils.loginToEntropy(newPassword)); - if (err != null) - logBadChangePass(err.getMessage()); - else - LOGGER.log(Level.INFO, "Wallet password changed successfully"); - - return; - } - case "--help": - displayHelp(); - return; // Exit after displaying help - } - } - } - - String entropy = null; - - while (entropy == null) { - LOGGER.log(Level.INFO, "-------------------------"); - LOGGER.log(Level.INFO, "1 - Create new wallet " + newWalletStr); - LOGGER.log(Level.INFO, "2 - Decrypt wallet"); - LOGGER.log(Level.INFO, "3 - Import from mnemonic"); - LOGGER.log(Level.INFO, "4 - Quit"); - - LOGGER.log(Level.INFO, "Selection: "); - selection = input.nextInt(); - input.nextLine(); // clear buffer - - switch (selection) { - case 1: { - if (KeyHandler.existsBaseECKeyFromLocal()) { - LOGGER.log(Level.INFO, "Key already exists"); - return; - } - - LOGGER.log(Level.INFO, "Enter new password: "); - String password = input.next(); - int strength = KeyHandler.calculatePasswordStrength(password); - - if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { - LOGGER.log(Level.INFO, "Bad password."); - return; - } - entropy = LoginUtils.loginToEntropy(password); - break; - } - case 2: { - LOGGER.log(Level.INFO, "Enter password: "); - String password = new String(System.console().readPassword()); - int strength = KeyHandler.calculatePasswordStrength(password); - - if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { - LOGGER.log(Level.INFO, "Bad password."); - return; - } - - entropy = LoginUtils.loginToEntropy(password); - break; - } - case 3: { - LOGGER.log(Level.INFO, "Enter mnemonic: "); - String mnemonicImport = input.nextLine().trim(); - - completeLogin(mnemonicImport, null, true); - return; - } - case 4: { - LOGGER.log(Level.INFO, "Exiting..."); - System.exit(0); - } - default: { - LOGGER.log(Level.INFO, "Unknown Option."); - } - } - } - - input.close(); - completeLogin(entropy, null, false); - } - - public void deinit() { - if (backgroundTimerThread != null) - backgroundTimerThread.stop(); - for (CoinTicker cointicker : CoinTicker.coins()) - CoinInstance.getInstance(cointicker).deinit(); - } - - private void completeLogin(String entropy, String userMnemonic, boolean isMnemonic) { - if (entropy == null && userMnemonic == null) { - logBadPassword(null); - System.exit(0); - } - - CoinInstance.CoinError coinError = CoinInstance.getInstance(CoinTicker.BLOCKNET).init(entropy, userMnemonic, isMnemonic, xliteRPC); - if (coinError != null) { - String msg = "[master] Error(" + coinError.getCode().name() + "): " + coinError.getMessage(); - LOGGER.log(Level.SEVERE, msg); - System.out.println(msg); - System.exit(0); - } - - for (CoinTicker cointicker : CoinTicker.coins()) { - if (cointicker == CoinTicker.BLOCKNET || cointicker == CoinTicker.BLOCKNET_TESTNET5) - continue; - coinError = CoinInstance.getInstance(cointicker).init(entropy, userMnemonic, isMnemonic, xliteRPC); - if (coinError != null) // fail silently - LOGGER.log(Level.WARNING, "[" + cointicker.name() + "] Error(" + coinError.getCode().name() + "): " + coinError.getMessage()); - } - - App.masterRPC.start(); - backgroundTimerThread = new BackgroundTimerThread(); - (new Thread(backgroundTimerThread)).start(); - - // Start EXR capability probing after wallet is decrypted - if (App.exrServerPool != null) { - App.exrServerPool.probeAllCapabilities(); - } - } - - private void autoGenerateRPCConfig() { - for (CoinTicker cointicker : CoinTicker.coins()) { - ConfigHelper configHelper = new ConfigHelper(CoinTickerUtils.tickerToString(cointicker)); - - configHelper.setRpcUsername(generateRandomString(24)); - configHelper.setRpcPassword(generateRandomString(32)); - configHelper.setRpcEnabled(true); - configHelper.writeConfig(); - } - } - - private String generateRandomString(int length) { - SecureRandom secureRandom = new SecureRandom(); - - byte[] token = new byte[length]; - secureRandom.nextBytes(token); - - return Base64.getUrlEncoder().withoutPadding().encodeToString(token); - } - - /** - * Reads the password from args or from stdin if password is not specified. - * @param input Stdin - * @param args Program arguments - * @param argPos Current arg position - * @param msg Message to display on stdin - * @return Password - */ - private String readPassword(Scanner input, String[] args, int argPos, String msg) { - if (msg.isEmpty()) - msg = "Password:\n"; - if (args.length <= argPos || args[argPos].contains("--")) { // ask pw on stdin - System.out.println(msg); - return input.nextLine(); // clear buffer - } - // get pw from args - return args[argPos]; - } - - // Function to display help information - private static void displayHelp() { - System.out.println("Usage: xlite-daemon [options]"); - System.out.println("Options:"); - System.out.println(" --enablerpcandconfigure Enable and configure RPC"); - System.out.println(" --development-endpoint Set a custom development endpoint"); - System.out.println(" Example: --development-endpoint "); - System.out.println(" --exr-endpoint Set EXR endpoint for EXR server"); - System.out.println(" Example: --exr-endpoint "); - System.out.println(" --version Display the version"); - System.out.println(" --createdefaultwallet Create a default wallet"); - System.out.println(" --createwalletmnemonic Create a wallet with a mnemonic"); - System.out.println(" --xliterpc Increment RPC port by 1"); - System.out.println(" --password Set password without prompt"); - System.out.println(" Example: --password "); - System.out.println(" --getmnemonic Retrieve mnemonic for a password"); - System.out.println(" Example: --getmnemonic "); - System.out.println(" --changepassword Change wallet password"); - System.out.println(" Example: --changepassword "); - } -} diff --git a/src/main/java/io/cloudchains/app/crypto/KeyHandler.java b/src/main/java/io/cloudchains/app/crypto/KeyHandler.java deleted file mode 100644 index 1741d78..0000000 --- a/src/main/java/io/cloudchains/app/crypto/KeyHandler.java +++ /dev/null @@ -1,247 +0,0 @@ -package io.cloudchains.app.crypto; - -import com.google.common.base.Joiner; -import com.subgraph.orchid.encoders.Base64; -import io.cloudchains.app.util.ConfigHelper; -import org.bitcoinj.core.ECKey; -import org.bitcoinj.crypto.MnemonicCode; -import org.bitcoinj.crypto.MnemonicException; -import org.bitcoinj.wallet.DeterministicSeed; -import org.bitcoinj.wallet.UnreadableWalletException; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.SecretKeySpec; -import java.io.*; -import java.security.SecureRandom; -import java.text.SimpleDateFormat; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - - -public class KeyHandler { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private ECKey ecKey; - - public KeyHandler(ECKey key) { - this.ecKey = key; - } - - public ECKey getBaseECKey() { - return this.ecKey; - } - - public ECKey getPublicKey() { - return ECKey.fromPublicOnly(this.ecKey.getPubKey()); - } - - - public static boolean existsBaseECKeyFromLocal() { - String keyPath = ConfigHelper.getLocalDataDirectory() + "key.dat"; - File keyFile = new File(keyPath); - - return keyFile.exists(); - } - - private static String encryptBaseSeed(String passphrase, byte[] seedBytes, byte[] salt) { - try { - SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); - PBEKeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt, 16384, 256); - SecretKey tmp = skf.generateSecret(spec); - SecretKey key = new SecretKeySpec(tmp.getEncoded(), "AES"); - - Cipher cipher = Cipher.getInstance("AES"); - cipher.init(Cipher.ENCRYPT_MODE, key); - byte[] encrypted = cipher.doFinal(seedBytes); - byte[] encryptedValue = Base64.encode(encrypted); - return new String(encryptedValue); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - public static List getBaseSeed(String passphrase) { - File keyFile = new File(ConfigHelper.getLocalDataDirectory() + "key.dat"); - BufferedReader bufferedReader; - - if (existsBaseECKeyFromLocal()) { - try { - bufferedReader = new BufferedReader(new FileReader(keyFile)); - String saltB64 = bufferedReader.readLine(); - String seedEncryptedB64 = bufferedReader.readLine(); - bufferedReader.close(); - - byte[] salt = Base64.decode(saltB64); - byte[] seedEncrypted = Base64.decode(seedEncryptedB64); - - SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); - PBEKeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt, 16384, 256); - SecretKey tmp = skf.generateSecret(spec); - SecretKey key = new SecretKeySpec(tmp.getEncoded(), "AES"); - - Cipher cipher = Cipher.getInstance("AES"); - cipher.init(Cipher.DECRYPT_MODE, key); - String seed = new String(cipher.doFinal(seedEncrypted)); - - return Arrays.asList(seed.split(" ")); - } catch (Exception e) { - LOGGER.log(Level.FINER, "Error while obtaining base seed: " + e); - LOGGER.log(Level.FINER, "Bad password."); - - return null; - } - } else { - - DeterministicSeed seed = null; - seed = new DeterministicSeed(new SecureRandom(), 128, "", System.currentTimeMillis() / 1000); - - String mnemonic = Joiner.on(" ").join(Objects.requireNonNull(seed.getMnemonicCode())); - - if (writeInitialData(keyFile, mnemonic, passphrase)) { - return seed.getMnemonicCode(); - } else { - return null; - } - } - } - - public static boolean importFromMnemonic(List mnemonicList, String passphrase) { - File keyFile = new File(ConfigHelper.getLocalDataDirectory() + "key.dat"); - byte[] entropy; - - try { - MnemonicCode mnemonicCode = new MnemonicCode(); - entropy = mnemonicCode.toEntropy(mnemonicList); - } catch (IOException | MnemonicException.MnemonicWordException | MnemonicException.MnemonicChecksumException | MnemonicException.MnemonicLengthException e) { - e.printStackTrace(); - return false; - } - - DeterministicSeed seed = new DeterministicSeed(entropy , "", System.currentTimeMillis() / 1000); - - String mnemonic = Joiner.on(" ").join(Objects.requireNonNull(seed.getMnemonicCode())); - - if (!seed.getMnemonicCode().toString().equals(mnemonicList.toString())) - return false; - - return writeInitialData(keyFile, mnemonic, passphrase); - } - - public static byte[] mnemonicToEntropy(List mnemonicList) { - byte[] entropy = null; - - try { - MnemonicCode mnemonicCode = new MnemonicCode(); - entropy = mnemonicCode.toEntropy(mnemonicList); - } catch (Exception e) { - e.printStackTrace(); - } - - return entropy; - } - - private static File findRenameFile() { - for (int i = 0; i < 100; i++) { - File file = new File(ConfigHelper.getLocalDataDirectory() + "key-" + i + ".dat"); - - if (!file.exists()) { - return file; - } - } - - return null; - } - - private static boolean writeInitialData(File keyFile, String mnemonic, String passphrase) { - BufferedWriter bufferedWriter; - - // Move current wallet file to backups - if (keyFile.exists()) { - // Backups dir - String backups = ConfigHelper.getLocalDataDirectory() + "backups" + File.separator; - File backupsDir = new File(backups); - if (!backupsDir.exists() && !backupsDir.mkdir()) - LOGGER.warning("Failed to create backups dir " + backupsDir.getPath()); - - Date date = new Date(); - SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMddHHmmss"); - String newFileName = backups + "key-" + formatter.format(date) + ".dat"; - - File oldFile = new File(keyFile.getPath()); - if (!oldFile.renameTo(new File(newFileName))) - LOGGER.info("Failed to rename old wallet file"); - else - LOGGER.info("Created wallet backup " + newFileName); - } - - Random r = new SecureRandom(); - byte[] salt = new byte[20]; - r.nextBytes(salt); - - byte[] mnemonicByte = mnemonic.getBytes(); - - String encryptedSeed = encryptBaseSeed(passphrase, mnemonicByte, salt); - - // Print mnemonic to console - // System.out.println(mnemonic + "\n"); - - try { - bufferedWriter = new BufferedWriter(new FileWriter(keyFile)); - if (encryptedSeed != null) { - bufferedWriter.write(new String(Base64.encode(salt))); - bufferedWriter.newLine(); - bufferedWriter.write(encryptedSeed); - bufferedWriter.newLine(); - } - bufferedWriter.flush(); - bufferedWriter.close(); - return true; - } catch (IOException e) { - e.printStackTrace(); - return false; - } - } - - public static List getMnemonicFromString(String mnemonic) { - return Arrays.asList(mnemonic.split(" ")); - } - - public static int calculatePasswordStrength(String password){ - // Password must be greater than 8 characters, contain at least one digit, one lowercase letter, one uppercase letter and one special character. - - int totalScore = 0; - - if( password.length() < 8 ) - return 0; - else if( password.length() >= 10 ) - totalScore += 2; - else - totalScore += 1; - - //if it contains one digit, add 2 to total score - if( password.matches("(?=.*[0-9]).*") ) - totalScore += 2; - - //if it contains one lower case letter, add 2 to total score - if( password.matches("(?=.*[a-z]).*") ) - totalScore += 2; - - //if it contains one upper case letter, add 2 to total score - if( password.matches("(?=.*[A-Z]).*") ) - totalScore += 2; - - //if it contains one special character, add 2 to total score - if( password.matches("(?=.*[~!@#$%^&*()_-]).*") ) - totalScore += 2; - - return totalScore; - } -} diff --git a/src/main/java/io/cloudchains/app/crypto/LoginUtils.java b/src/main/java/io/cloudchains/app/crypto/LoginUtils.java deleted file mode 100644 index a7430e8..0000000 --- a/src/main/java/io/cloudchains/app/crypto/LoginUtils.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.cloudchains.app.crypto; - -import java.security.MessageDigest; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class LoginUtils { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private static String toSha256(String message) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - digest.update((message).getBytes()); - byte[] hash = digest.digest(); - StringBuilder hex = new StringBuilder(); - for (byte b : hash) { - if ((0xff & b) < 0x10) - hex.append("0").append(Integer.toHexString((0xFF & b))); - else - hex.append(Integer.toHexString(0xFF & b)); - } - return hex.toString(); - } catch (Exception e) { - LOGGER.log(Level.FINER, "Error while hashing message with SHA256!"); - e.printStackTrace(); - } - return null; - } - - public static String loginToEntropy(String password) { - String shaPassword = toSha256(password); - - if (shaPassword == null) { - LOGGER.log(Level.FINER, "Password hashing failed"); - return null; - } - - return shaPassword; - } - -} diff --git a/src/main/java/io/cloudchains/app/net/ActiveCoinChangedEventListener.java b/src/main/java/io/cloudchains/app/net/ActiveCoinChangedEventListener.java deleted file mode 100644 index c55092b..0000000 --- a/src/main/java/io/cloudchains/app/net/ActiveCoinChangedEventListener.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.cloudchains.app.net; - -public interface ActiveCoinChangedEventListener { - - void onActiveCoinChanged(CoinInstance newActiveCoin); - -} diff --git a/src/main/java/io/cloudchains/app/net/CoinInstance.java b/src/main/java/io/cloudchains/app/net/CoinInstance.java deleted file mode 100644 index a8fe647..0000000 --- a/src/main/java/io/cloudchains/app/net/CoinInstance.java +++ /dev/null @@ -1,1070 +0,0 @@ -package io.cloudchains.app.net; - -import com.google.common.base.Joiner; -import com.google.common.base.Preconditions; -import com.google.common.util.concurrent.AtomicDouble; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -//import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.App; -import io.cloudchains.app.Version; -import io.cloudchains.app.crypto.KeyHandler; -import io.cloudchains.app.net.api.JSONRPCController; -import io.cloudchains.app.net.api.JSONRPCServer; -//import io.cloudchains.app.net.protocols.alqocoin.AlqocoinNetworkParameters; -//import io.cloudchains.app.net.protocols.bitbay.BitbayNetworkParameters; -//import io.cloudchains.app.net.protocols.bitcoincash.BitcoinCashNetworkParameters; -import io.cloudchains.app.net.protocols.blocknet.*; -import io.cloudchains.app.net.protocols.dashcoin.DashcoinNetworkParameters; -//import io.cloudchains.app.net.protocols.digibyte.DigibyteNetworkParameters; -import io.cloudchains.app.net.protocols.dogecoin.DogecoinNetworkParameters; -import io.cloudchains.app.net.protocols.litecoin.LitecoinNetworkParameters; -//import io.cloudchains.app.net.protocols.phorecoin.PhorecoinNetworkParameters; -import io.cloudchains.app.net.protocols.pivx.PivxNetworkParameters; -//import io.cloudchains.app.net.protocols.poliscoin.PoliscoinNetworkParameters; -//import io.cloudchains.app.net.protocols.ravencoin.RavencoinNetworkParameters; -import io.cloudchains.app.net.protocols.syscoin.SyscoinNetworkParameters; -import io.cloudchains.app.net.protocols.unobtanium.UnobtaniumNetworkParameters; -//import io.cloudchains.app.net.protocols.trezarcoin.TrezarcoinNetworkParameters; -import io.cloudchains.app.net.xrouter.XRouterMessage; -import io.cloudchains.app.net.xrouter.XRouterMessageSerializer; -import io.cloudchains.app.net.xrouter.XRouterPacketManager; -import io.cloudchains.app.util.AddressBalance; -import io.cloudchains.app.util.AddressDiscoveryService; -import io.cloudchains.app.util.CloudTransaction; -import io.cloudchains.app.util.ConfigHelper; -import io.cloudchains.app.util.UTXO; -import io.cloudchains.app.util.history.Transaction; -import io.cloudchains.app.wallet.WalletHelper; -import org.bitcoinj.core.*; -import org.bitcoinj.params.MainNetParams; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.store.SPVBlockStore; -import org.bitcoinj.utils.BtcFormat; -import org.bitcoinj.utils.ListenerRegistration; -import org.bitcoinj.utils.MonetaryFormat; -import org.bitcoinj.utils.Threading; -import org.bitcoinj.wallet.DeterministicSeed; -import org.bitcoinj.wallet.Wallet; -import org.json.JSONArray; - -import java.io.File; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; -import com.subgraph.orchid.encoders.Hex; - -public class CoinInstance { - public static class CoinError { - public enum CoinErrorCode { - BADPASSWORD, UNSUPPORTEDCOIN, BADMNEMONIC, CHANGEPASSWORDFAILED - } - private final CoinErrorCode code; - private final String msg; - public CoinError(String msg, CoinErrorCode code) { - this.msg = msg; - this.code = code; - } - public CoinErrorCode getCode() { - return this.code; - } - public String getMessage() { - return this.msg; - } - } - - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private static final int MINIMUM_UTXO_UPDATE_INTERVAL = 500; - - private static final int FORWARD_ADDRESS_COUNT = 0; - - private static ArrayList coinInstances = new ArrayList<>(); - private static CoinInstance activeCurrency; - private static CoinTicker activeBlocknetNetwork = null; - private static CopyOnWriteArrayList> activeCoinChangedListeners = new CopyOnWriteArrayList<>(); - private static HashMap blockCounts = new HashMap<>(); - private static HashMap relayFees = new HashMap<>(); - - private ConfigHelper configHelper; - private WalletHelper walletHelper = null; - private CoinTicker ticker; - private ConcurrentHashMap transactionList = new ConcurrentHashMap<>(); - private ArrayList addressKeyPairs = new ArrayList<>(); - private ArrayList transactionObservableList = new ArrayList<>(); - private BlocknetPeerGroup blocknetPeerGroup; - private BlocknetParameters blocknetNetworkParameters; - private NetworkParameters networkParameters; - private Wallet wallet; - private BlockChain chain; - private boolean hasXRouter = false; - private KeyHandler keyHandler; - private XRouterPacketManager xRouterPacketManager = null; - private int rpcPort = -1; - private boolean testnet = false; - private JSONRPCServer coinRPCServer = null; - private long lastUtxoUpdate = 0; - private int updateFailures = 0; - private int generatedAddressCount; - private AddressDiscoveryService discoveryService = null; - private static boolean addressDiscoveryEnabled = true; - - private CoinInstance(CoinTicker ticker) { - this.ticker = ticker; - this.configHelper = new ConfigHelper(CoinTickerUtils.tickerToString(getTicker())); - - addBlockCount(ticker, 0); - - if (isBlocknetNetwork()) - setActiveCurrency(this); - } - - /** - * Return mnemonic seed from wallet stored on disk. Correct passphrase required. - * Returns empty string on error or failure to retrieve mnemonic (or if mnemonic - * doesn't exist). - * @param pw String - * @return String - */ - public static String getMnemonicForPw(String pw) { - if (!KeyHandler.existsBaseECKeyFromLocal()) - return ""; - - List seed = KeyHandler.getBaseSeed(pw); - if (seed == null) - return ""; - - return Joiner.on(" ").join(seed); - } - public static int getBlockCountByTicker(CoinTicker ticker) { - if (!blockCounts.containsKey(ticker)) { - return -1; - } - - return blockCounts.get(ticker).get(); - } - - public static double getRelayFeeByTicker(CoinTicker ticker) { - if (!relayFees.containsKey(ticker)) { - return -1; - } - - return relayFees.get(ticker).get(); - } - - public static ArrayList getCoinInstances() { - return coinInstances; - } - - public boolean isBlocknetNetwork() { - return getTicker() == CoinTicker.BLOCKNET || getTicker() == CoinTicker.BLOCKNET_TESTNET5; - } - - public static void setActiveCurrency(CoinInstance newActiveCurrency) { - activeCurrency = newActiveCurrency; - - for (ListenerRegistration registration : activeCoinChangedListeners) { - if (registration.executor == Threading.SAME_THREAD) { - registration.listener.onActiveCoinChanged(activeCurrency); - } - } - } - - public XRouterPacketManager getXRouterPacketManager() { - return xRouterPacketManager; - } - - public static void addActiveCoinChangedListener(ActiveCoinChangedEventListener listener) { - activeCoinChangedListeners.add(new ListenerRegistration<>(listener, Threading.SAME_THREAD)); - } - - public static CoinInstance getActiveCurrency() { - return activeCurrency; - } - - public AddressBalance getAddress(String addressB58) { - for (AddressBalance address : addressKeyPairs) { - if (address.getAddress().toBase58().equals(addressB58)) - return address; - } - - return null; - } - - public AddressBalance generateAddress(boolean updateConfig) { - AddressBalance addressKeyPair = getWalletHelper().generateAddress(); - Address address = addressKeyPair.getAddress(); - DumpedPrivateKey privateKey = addressKeyPair.getPrivateKey(); - addressKeyPairs.add(addressKeyPair); - LOGGER.log(Level.FINER, "[wallet] DEBUG: Generated new address, have " + addressKeyPairs.size() + ": " + address.toBase58() + ", private key: " + privateKey.toBase58() + " (hex: " + privateKey.getKey().getPrivateKeyAsHex() + ")"); - - if (updateConfig) { - configHelper.setAddressCount(configHelper.getAddressCount() + 1); - configHelper.writeConfig(); - generatedAddressCount = configHelper.getAddressCount(); - } - - return addressKeyPair; - } - - public void importPrivateKey(String privKey) { - AddressBalance addressKeyPair = getWalletHelper().generateFromPrivateKey(privKey); - AddressBalance addrExists = addressKeyPairs.stream() - .filter(e -> e.getAddress().equals(addressKeyPair.getAddress())).findAny().orElse(null); - - if (addrExists == null) - addressKeyPairs.add(addressKeyPair); - } - - public CoinTicker getTicker() { - return ticker; - } - - private static CoinInstance getInstanceByTicker(CoinTicker ticker) { - for (CoinInstance instance : coinInstances) { - if (instance.getTicker() == ticker) { - return instance; - } - } - - return null; - } - - public static CoinTicker getActiveBlocknetNetwork() { - return activeBlocknetNetwork; - } - - public static CoinInstance getInstance(CoinTicker ticker) { - CoinInstance instance = getInstanceByTicker(ticker); - if (getActiveBlocknetNetwork() != null && (ticker == CoinTicker.BLOCKNET || ticker == CoinTicker.BLOCKNET_TESTNET5)) { - return getInstanceByTicker(activeBlocknetNetwork); - } - - if (ticker == CoinTicker.BLOCKNET || ticker == CoinTicker.BLOCKNET_TESTNET5) { - activeBlocknetNetwork = ticker; - LOGGER.log(Level.FINER, "[coin] Initialized active Blocknet network: " + ticker.toString()); - LOGGER.log(Level.FINER, "[coin] All subsequent calls to this function requesting a Blocknet network will return the above regardless of testnet or mainnet status."); - } - - if (instance == null) { - instance = new CoinInstance(ticker); - if (ticker == CoinTicker.BLOCKNET) - coinInstances.add(0, instance); - else - coinInstances.add(instance); - } - - return instance; - } - - /** - * Change the password. Recreates the wallet file and encrypts with new password. - * @param oldPassword - * @param newPassword - * @return Error or null - */ - public static CoinError changePassword(String oldPassword, String newPassword) { - if (!KeyHandler.existsBaseECKeyFromLocal()) { - LOGGER.log(Level.FINER, "[wallet] Unable to change the password: Wallet not found on disk"); - return new CoinError("Unable to change the password: Wallet not found on disk", - CoinError.CoinErrorCode.CHANGEPASSWORDFAILED); - } - - List baseSeed = KeyHandler.getBaseSeed(oldPassword); - if (baseSeed == null) { - LOGGER.log(Level.FINER, "[wallet] Unable to change the password: Incorrect password"); - return new CoinError("Unable to change the password: Incorrect password", - CoinError.CoinErrorCode.CHANGEPASSWORDFAILED); - } - - // Get current wallet seed - DeterministicSeed seed = new DeterministicSeed(baseSeed, null, "", System.currentTimeMillis() / 1000); - List mnemonic = seed.getMnemonicCode(); - - if (!KeyHandler.importFromMnemonic(mnemonic, newPassword)) { - LOGGER.log(Level.FINER, "[wallet] Unable to change the password: Failed to create new wallet file"); - return new CoinError("Unable to change the password: Failed to create new wallet file", - CoinError.CoinErrorCode.CHANGEPASSWORDFAILED); - } - - return null; - } - - public NetworkParameters getNetworkParameters() { - return networkParameters; - } - - private BlocknetParameters getBlocknetNetworkParameters() { - return blocknetNetworkParameters; - } - - public boolean hasXRouter() { - return hasXRouter; - } - - public void deinit() { - if (getTicker() == getActiveBlocknetNetwork() && blocknetPeerGroup != null) { - blocknetPeerGroup.stop(); - } - - if (coinRPCServer != null) { - try { - coinRPCServer.deinit(); - coinRPCServer.join(); - } catch (Exception e) { - LOGGER.log(Level.FINER, "[coin] ERROR: Error while deinitializing coin RPC server!"); - e.printStackTrace(); - } - } - } - - public CoinError init(String pw, String userMnemonic, boolean isMnemonic) { - return init(pw, userMnemonic, isMnemonic, false); - } - - public CoinError init(String pw, String userMnemonic, boolean isMnemonic, boolean xliteRPC) { - switch (ticker) { - case BLOCKNET: { - LOGGER.log(Level.FINER, "[coin] Initializing for Blocknet main network."); - blocknetNetworkParameters = new BlocknetNetworkParameters(); - networkParameters = blocknetNetworkParameters; - hasXRouter = true; - rpcPort = 41419; - break; - } - case BLOCKNET_TESTNET5: { - LOGGER.log(Level.FINER, "[coin] Initializing for Blocknet test network v5."); - blocknetNetworkParameters = new BlocknetTestnet5NetworkParameters(); - networkParameters = blocknetNetworkParameters; - hasXRouter = true; - rpcPort = 41419; - testnet = true; - break; - } - case BITCOIN: { - LOGGER.log(Level.FINER, "[coin] Initializing for Bitcoin main network."); - networkParameters = MainNetParams.get(); - rpcPort = 8332; - break; - } - // case BITCOIN_CASH: { - // LOGGER.log(Level.FINER, "[coin] Initializing for BitcoinCash main network."); - // networkParameters = new BitcoinCashNetworkParameters(); - // rpcPort = 48332; - // break; - // } - case LITECOIN: { - LOGGER.log(Level.FINER, "[coin] Initializing for Litecoin main network."); - networkParameters = new LitecoinNetworkParameters(); - rpcPort = 9332; - break; - } - case DASHCOIN: { - LOGGER.log(Level.FINER, "[coin] Initializing for Dashcoin main network."); - networkParameters = new DashcoinNetworkParameters(); - rpcPort = 9998; - break; - } - // case DIGIBYTE: { - // LOGGER.log(Level.FINER, "[coin] Initializing for Digibyte main network."); - // networkParameters = new DigibyteNetworkParameters(); - // rpcPort = 14022; - // break; - // } - case DOGECOIN: { - LOGGER.log(Level.FINER, "[coin] Initializing for Dogecoin main network."); - networkParameters = new DogecoinNetworkParameters(); - rpcPort = 22555; - break; - } - case SYSCOIN: { - LOGGER.log(Level.FINER, "[coin] Initializing for Syscoin main network."); - networkParameters = new SyscoinNetworkParameters(); - rpcPort = 8370; - break; - } - // case TREZARCOIN: { - // networkParameters = new TrezarcoinNetworkParameters(); - // rpcPort = 17299; - // break; - // } - // case BITBAY: { - // networkParameters = new BitbayNetworkParameters(); - // rpcPort = 19915; - // break; - // } - case PIVX: { - LOGGER.log(Level.FINER, "[coin] Initializing for Pivx main network."); - networkParameters = new PivxNetworkParameters(); - rpcPort = 9951; - break; - } - case UNOBTANIUM: { - LOGGER.log(Level.FINER, "[coin] Initializing for Unobtanium main network."); - networkParameters = new UnobtaniumNetworkParameters(); - rpcPort = 65111; - break; - } - // case ALQOCOIN: { - // LOGGER.log(Level.FINER, "[coin] Initializing for Alqo main network."); - // networkParameters = new AlqocoinNetworkParameters(); - // rpcPort = 55000; - // break; - // } - // case POLISCOIN: { - // LOGGER.log(Level.FINER, "[coin] Initializing for Polis main network."); - // networkParameters = new PoliscoinNetworkParameters(); - // rpcPort = 24127; - // break; - // } - // case PHORECOIN: { - // LOGGER.log(Level.FINER, "[coin] Initializing for Phore main network."); - // networkParameters = new PhorecoinNetworkParameters(); - // rpcPort = 11772; - // break; - // } - // case RAVENCOIN: { - // LOGGER.log(Level.FINER, "[coin] Initializing for Ravencoin main network."); - // networkParameters = new RavencoinNetworkParameters(); - // rpcPort = 8766; - // break; - // } - default: { - LOGGER.log(Level.FINER, "[coin] ERROR: Invalid/unsupported network: " + ticker.toString()); - return new CoinError("Unsupported coin", CoinError.CoinErrorCode.UNSUPPORTEDCOIN); - } - } - - if (xliteRPC) { - rpcPort = rpcPort + 1; - - configHelper.setRpcPort(rpcPort); - configHelper.writeConfig(); - } - - Context.propagate(new Context(networkParameters)); - - List baseSeed; - boolean existsOnDisk = false; - - if (isMnemonic) { - baseSeed = Arrays.asList(pw.split(" ")); - } else { - if (KeyHandler.existsBaseECKeyFromLocal()) - existsOnDisk = true; - else if (userMnemonic != null) { - if (!KeyHandler.importFromMnemonic(Arrays.asList(new String(userMnemonic).split(" ")), pw)) { - LOGGER.log(Level.FINER, "[wallet] Unable to create wallet from mnemonic"); - return new CoinError("Unable to create wallet from mnemonic", CoinError.CoinErrorCode.BADMNEMONIC); - } - } - - baseSeed = KeyHandler.getBaseSeed(pw); - } - - if (baseSeed == null) { - LOGGER.log(Level.FINER, "[wallet] Possible Bad password: Unable to import or create base seed!"); - return new CoinError("Bad password", CoinError.CoinErrorCode.BADPASSWORD); - } - - // In-memory wallet only - DeterministicSeed seed = new DeterministicSeed(baseSeed, null, "", System.currentTimeMillis() / 1000); - wallet = Wallet.fromSeed(networkParameters, seed); - if (isBlocknetNetwork()) { - String mnemonic = getMnemonic(); - // LOGGER.log(Level.FINE, "[wallet] Mnemonic = " + mnemonic); - } - - // RUN ADDRESS DISCOVERY ONLY DURING WALLET INITIALIZATION - // This ensures discovery runs once at wallet startup in ANY case - if (addressDiscoveryEnabled) { - LOGGER.log(Level.INFO, "[coin] Running address discovery"); - runAddressDiscovery(); - } else { - LOGGER.log(Level.INFO, "[coin] Address discovery disabled"); - } - - // Make sure wallet addresses are available - generateForwardAddresses(true); - - if (configHelper.getRpcPort() == -1000) { - configHelper.setRpcPort(rpcPort); - configHelper.writeConfig(); - } else { - rpcPort = configHelper.getRpcPort(); - } - - if (configHelper.isRpcEnabled() && configHelper.validAuth() && rpcPort != -1) { - coinRPCServer = JSONRPCController.getRPCServer(this); - LOGGER.log(Level.INFO, "[rpc] Starting JSON-RPC server for coin " + CoinTickerUtils.tickerToString(getTicker()) + " on port " + getRPCPort()); - - if (coinRPCServer.isAlive()) - coinRPCServer.deinit(); - - coinRPCServer.start(); - } - - // Blocknet Network / XRouter not used (Dec 10) -// if (isBlocknetNetwork() && hasXRouter()) { -// XRouterMessageSerializer xRouterMessageSerializer = (getBlocknetNetworkParameters()).getXRouterMessageSerializer(false); -// xRouterPacketManager = new XRouterPacketManager(xRouterMessageSerializer, blocknetNetworkParameters); -// LOGGER.log(Level.FINER, "[coin] This network is a Blocknet network and supports XRouter. Our packet version is " + Integer.toString(XRouterPacketManager.getXRouterPacketVersion(), 16)); -// } else { -// LOGGER.log(Level.FINER, "[coin] WARNING: This network (" + CoinTickerUtils.tickerToString(getTicker()) + ") does not support XRouter."); -// } -// -// if (isBlocknetNetwork()) { -// String userHome = ConfigHelper.getLocalDataDirectory(); -// Preconditions.checkNotNull(userHome); -// -// File spvDat = new File(userHome,"spv-" + CoinTickerUtils.tickerToString(getTicker()) + ".dat"); -// try { -// chain = new BlockChain(networkParameters, getWallet(), new SPVBlockStore(networkParameters, spvDat)); -// } catch (BlockStoreException e) { -// try { -// chain = new BlockChain(networkParameters, getWallet(), new SPVBlockStore(networkParameters, spvDat)); -// } catch (BlockStoreException ex) { -// LOGGER.log(Level.FINER, "Error while initializing blockchain object!"); -// ex.printStackTrace(); -// return false; -// } -// } -// -// LOGGER.log(Level.INFO, "[coin] Connecting to the (" + getTicker().toString() + ") network."); -// -// if (getAddressKeyPairs().size() == 0) { -// LOGGER.log(Level.FINER, "[peer] Have no addresses. Generating forward addresses."); -// -// generateForwardAddresses(true); -// } -// -// AddressBalance blockProofAddress = getAddressKeyPairs().get(0); -// -// keyHandler = new KeyHandler(blockProofAddress.getPrivateKey().getKey()); -// -// blocknetPeerGroup = new BlocknetPeerGroup(this, (BlocknetNetworkParameters) blocknetNetworkParameters, chain); -// connectToBlocknetNetwork(); -// } - - return null; - } - - private void generateForwardAddresses(boolean fromStartup) { - int configAddressCount = configHelper.getAddressCount(); - boolean updateConfig = false; - if (configAddressCount < FORWARD_ADDRESS_COUNT) { // minimum starting addresses - configAddressCount = FORWARD_ADDRESS_COUNT; - updateConfig = true; - } - - LOGGER.log(Level.FINER, "[wallet] Generating " + configAddressCount + " forward addresses for network " + getTicker().toString() + "."); - - // Ensure that internal HD wallet pointer matches the count we're expecting. - // Required because wallet doesn't remember last HD wallet address prior to - // reboot. - if (fromStartup) { - for (int i = 0; i < generatedAddressCount; i++) { - getWalletHelper().generateAddress(); - } - } - for (int i = generatedAddressCount; i < configAddressCount; i++) { - generateAddress(false); - } - generatedAddressCount = configAddressCount; - - if (updateConfig) { - configHelper.setAddressCount(configAddressCount); - configHelper.writeConfig(); - } - } - - private void connectToBlocknetNetwork() { - try { - blocknetPeerGroup.start(); - } catch (Exception e) { - LOGGER.log(Level.FINER, "Error while initializing blocking client object!"); - e.printStackTrace(); - return; - } - - LOGGER.log(Level.FINER, "[coin] This network is connecting/connected."); - } - - public Wallet getWallet() { - return wallet; - } - - public String getMnemonic() { - return Joiner.on(" ").join(Objects.requireNonNull(getWallet().getKeyChainSeed().getMnemonicCode())); - } - - public double getAllBalances() { - double balance = 0; - - for (AddressBalance inst : getAddressKeyPairs()) { - balance += inst.getBalanceProp(); - } - - return balance; - } - - public String getAllBalancesFormatted() { - BtcFormat f = BtcFormat.getInstance(BtcFormat.COIN_SCALE); - return f.format(Coin.valueOf((long) (getAllBalances() * Coin.COIN.value))); - } - - public void sendXrGetTransaction(BlocknetPeer blocknetPeer, String txid) { - String currentCurrency = CoinTickerUtils.tickerToString(getTicker()); - - HashMap body = new HashMap<>(); - body.put("currency", currentCurrency); - body.put("txid", txid); - - getInstance(activeBlocknetNetwork).sendXrMessage(blocknetPeer, "xrGetTransaction", body); - } - - public void sendXrGetBlockCount(BlocknetPeer blocknetPeer) { - String currentCurrency = CoinTickerUtils.tickerToString(getTicker()); - - HashMap body = new HashMap<>(); - body.put("currency", currentCurrency); - - getInstance(activeBlocknetNetwork).sendXrMessage(blocknetPeer, "xrGetBlockCount", body); - } - - public void sendXrGetUtxos(BlocknetPeer blocknetPeer) { - if (System.currentTimeMillis() - lastUtxoUpdate < MINIMUM_UTXO_UPDATE_INTERVAL) { - LOGGER.log(Level.FINER, "[coin] Aborting UTXO checking as the list was updated less than 1 second ago."); - return; - } - - String currentCurrency = CoinTickerUtils.tickerToString(getTicker()); - - HashMap body = new HashMap<>(); - body.put("currency", currentCurrency); - body.put("command", "xrmgetutxos"); - body.put("params", getInstance(CoinTickerUtils.stringToTicker(currentCurrency)).getUTXOParams()); - - getInstance(activeBlocknetNetwork).sendXrMessage(blocknetPeer, "xrService", body); - } - - public String sendXrMessage(BlocknetPeer blocknetPeer, String command, HashMap params) { - return sendXrMessage(blocknetPeer, UUID.randomUUID().toString(), command, params); - } - - public String sendXrMessage(BlocknetPeer blocknetPeer, String uuid, String command, HashMap params) { - XRouterMessage message = null; - - if (blocknetPeer == null || !blocknetPeer.getHaveConfig().get()) { - LOGGER.log(Level.FINER, "[sendXrMessage] Config not received yet"); - return null; - } - - switch (command) { - case "xrGetBlockCount": { - String currency = (String) params.get("currency"); - - message = getXRouterPacketManager().getXrGetBlockCount( - blocknetPeer, - uuid, - currency, - keyHandler.getBaseECKey(), - keyHandler.getPublicKey()); - break; - } - case "xrService": { - String xrCustomCmd = (String) params.get("command"); - - ArrayList paramsList = (ArrayList) params.get("params"); - - message = getXRouterPacketManager().getXrService( - blocknetPeer, - uuid, - xrCustomCmd, - paramsList, - keyHandler.getBaseECKey(), - keyHandler.getPublicKey()); - break; - } - case "xrSendTransaction": { - String feePayment = (String) params.get("feetx"); - String transaction = (String) params.get("transaction"); - String currency = (String) params.get("currency"); - - message = getXRouterPacketManager().getXrSendTransaction( - blocknetPeer, - uuid, - feePayment, - currency, - transaction, - keyHandler.getBaseECKey(), - keyHandler.getPublicKey()); - break; - } - case "xrGetBlockHash": { - String feePayment = (String) params.get("feetx"); - String blockIndex = (String) params.get("blockIndex"); - String currency = (String) params.get("currency"); - - message = getXRouterPacketManager().getXrGetBlockHash( - blocknetPeer, - uuid, - feePayment, - currency, - blockIndex, - keyHandler.getBaseECKey(), - keyHandler.getPublicKey()); - break; - } - case "xrGetBlock": { - String feePayment = (String) params.get("feetx"); - String blockHash = (String) params.get("blockHash"); - String currency = (String) params.get("currency"); - - message = getXRouterPacketManager().getXrGetBlock( - blocknetPeer, - uuid, - feePayment, - currency, - blockHash, - keyHandler.getBaseECKey(), - keyHandler.getPublicKey()); - break; - } - case "xrGetTransaction": { - String txid = (String) params.get("txid"); - String currency = (String) params.get("currency"); - - message = getXRouterPacketManager().getXrGetTransaction( - blocknetPeer, - uuid, - currency, - txid, - keyHandler.getBaseECKey(), - keyHandler.getPublicKey()); - break; - } - case "xrGetConfig": { - message = getXRouterPacketManager().getXrGetConfig( - blocknetPeer, - uuid, - "self", - keyHandler.getBaseECKey(), - keyHandler.getPublicKey()); - break; - } - default: { - LOGGER.log(Level.FINER, "[coin] ERROR: Unknown XRouter Message! Command: " + command); - uuid = null; - break; - } - } - - if (message != null) - blocknetPeerGroup.sendMessage(blocknetPeer, message); - - return uuid; - } - - public JsonArray getAllUTXOS() { - MonetaryFormat PLAIN_FORMAT = MonetaryFormat.BTC.minDecimals(8).repeatOptionalDecimals(1, 0).noCode(); - - JsonArray unspentTxsJSON = new JsonArray(); - for (AddressBalance addressBalance : getAddressKeyPairs()) { - for (UTXO utxo : addressBalance.getUtxos()) { - if (utxo.isSpent()) - continue; - - org.bitcoinj.core.UTXO bUtxo = utxo.createUTXO(); - - JsonObject utxoJSON = new JsonObject(); - utxoJSON.addProperty("txid", bUtxo.getHash().toString()); - utxoJSON.addProperty("vout", bUtxo.getIndex()); - utxoJSON.addProperty("address", bUtxo.getAddress()); - - Monetary monetary = new Monetary() { - @Override - public int smallestUnitExponent() { - return 8; - } - - @Override - public long getValue() { - return utxo.getValue(); - } - - @Override - public int signum() { - if (this.getValue() == 0) - return 0; - return this.getValue() < 0 ? -1 : 1; - } - }; - - BigDecimal amountDecimal = new BigDecimal(PLAIN_FORMAT.format(monetary).toString()); - - utxoJSON.addProperty("amount", amountDecimal); - utxoJSON.addProperty("scriptPubKey", new String(Hex.encode(bUtxo.getScript().getProgram()))); - utxoJSON.addProperty("spendable", true); - - int totalBlocks = CoinInstance.getBlockCountByTicker(getTicker()); - int confirmations = (totalBlocks - bUtxo.getHeight()) + 1; - if (bUtxo.getHeight() == 0) - confirmations = 0; - - utxoJSON.addProperty("confirmations", confirmations); - - unspentTxsJSON.add(utxoJSON); - } - } - - return unspentTxsJSON; - } - - public JsonArray getAllTransactions() { - JsonArray transactionsJSON = new JsonArray(); - for (Transaction tx : transactionList.values()) { - JsonObject txJSON = new JsonObject(); - txJSON.addProperty("category", tx.getCategory()); - txJSON.addProperty("txid", tx.getTxid()); - txJSON.addProperty("blockhash", tx.getBlockhash()); - txJSON.addProperty("vout", tx.getVout()); - txJSON.addProperty("address", tx.getAddress()); - - txJSON.addProperty("amount", tx.getValue()); - txJSON.addProperty("fee", tx.getFee()); - txJSON.addProperty("trusted", false); - txJSON.addProperty("blocktime", tx.getBlocktime()); - txJSON.addProperty("time", tx.getBlocktime()); - - txJSON.addProperty("confirmations", tx.getConfirmations()); - - transactionsJSON.add(txJSON); - } - - - return transactionsJSON; - } - - public ArrayList getUTXOParams() { - ArrayList params = new ArrayList<>(); - params.add(CoinTickerUtils.tickerToString(getTicker())); - - JSONArray utxoAddresses = new JSONArray(); - for (AddressBalance addressBalance : getAddressKeyPairs()) { - utxoAddresses.put(addressBalance.getAddress().toBase58()); - } - - params.add(utxoAddresses.toString()); - - return params; - } - - public AddressBalance getAddressBalance(String address) { - for (AddressBalance addressBalance : getAddressKeyPairs()) { - if (addressBalance.getAddress().toBase58().equals(address)) { - return addressBalance; - } - } - - return null; - } - - public ConfigHelper getConfigHelper() { - return this.configHelper; - } - - public WalletHelper getWalletHelper() { - if (this.walletHelper == null) - this.walletHelper = new WalletHelper(this); - - return this.walletHelper; - } - - public void addBlockCount(CoinTicker ticker, Integer blockCount) { - if (blockCounts.containsKey(ticker)) { - if (blockCounts.get(ticker).get() > blockCount) - return; - - blockCounts.get(ticker).set(blockCount); - return; - } - - blockCounts.put(ticker, new AtomicInteger(blockCount)); - } - - public void addRelayFee(CoinTicker ticker, Double relayFee) { - if (relayFees.containsKey(ticker)) { - relayFees.get(ticker).set(relayFee); - return; - } - - configHelper.setFee(relayFee); - - relayFees.put(ticker, new AtomicDouble(relayFee)); - } - - public void addCloudTransaction(CloudTransaction cloudTransaction) { - if (transactionObservableList.isEmpty()) { - transactionObservableList.add(cloudTransaction); - return; - } - - CloudTransaction tx = transactionObservableList.stream() - .filter(e -> e.getTxHash().equals(cloudTransaction.getTxHash())).findAny().orElse(null); - - if (tx == null) { - transactionObservableList.add(cloudTransaction); - } - } - - public void processUtxos(List utxoList) { - // first lets clear UTXOs out of each address - for (UTXO utxo : utxoList) { - AddressBalance addressBalance = getAddress(utxo.getAddress()); - addressBalance.clearUtxos(); - } - - // now let's add them back - for (UTXO utxo : utxoList) { - AddressBalance addressBalance = getAddress(utxo.getAddress()); - - if (addressBalance == null) { - LOGGER.log(Level.FINER, "[utxo-parser] Warning: Encountered non-tracked address in reply: " + utxo.getAddress()); - continue; - } - - boolean isNewUtxo = addressBalance.addUtxo(utxo); - - if (isNewUtxo) { - addCloudTransaction(new CloudTransaction(utxo)); - LOGGER.log(Level.FINER, "[utxo-parser] Added new UTXO, address: " + utxo.getAddress() + " value: " + utxo.getAmount()); - } - } - - setLastUtxoUpdate(System.currentTimeMillis()); - } - - public void processHistoryTxs(List transactions) { - for (Transaction tx : transactions) - transactionList.put(tx.uid(), tx); - } - - private void setLastUtxoUpdate(long newUtxoTime) { - lastUtxoUpdate = newUtxoTime; - } - - public void reloadConfig() { - this.configHelper.loadConfig(); - rpcPort = configHelper.getRpcPort(); - if (configHelper.getAddressCount() != generatedAddressCount) - generateForwardAddresses(false); - - if (coinRPCServer == null) - return; // no rpc available, skip - - JSONRPCController.removeRPCServer(this); - - coinRPCServer = JSONRPCController.getRPCServer(this); - - LOGGER.log(Level.INFO, "[rpc] Starting JSON-RPC server for coin " + CoinTickerUtils.tickerToString(getTicker()) + " on port " + getRPCPort()); - coinRPCServer.start(); - } - - public KeyHandler getKeyHandler() { - return keyHandler; - } - - public BlocknetPeerGroup getBlocknetPeerGroup() { - return blocknetPeerGroup; - } - - public BlocknetPeer getBestBlocknetPeer(String currency) { - if (blocknetPeerGroup == null) - return null; - - return blocknetPeerGroup.getBestBlocknetPeer(currency); - } - - public ArrayList getAddressKeyPairs() { - return addressKeyPairs; - } - - public ArrayList getTransactionList() { - return transactionObservableList; - } - - public static AtomicInteger getBlockCount(CoinTicker ticker) { - return blockCounts.putIfAbsent(ticker, new AtomicInteger(0)); - } - - public static HashMap getBlockCounts() { - return blockCounts; - } - - public int getRPCPort() { - return rpcPort; - } - - public boolean isTestnet() { - return testnet; - } - - public static String getVersionString() { - return Version.SUBVERSION; - } - - public void incrementUpdateFailures() { - updateFailures += 1; - } - - public void resetUpdateFailures() { - updateFailures = 0; - } - - public void runAddressDiscovery() { - String currency = CoinTickerUtils.tickerToString(this.getTicker()); - - if (discoveryService == null) { - discoveryService = new AddressDiscoveryService(this); - LOGGER.log(Level.INFO, "[coin-" + currency + "] AddressDiscoveryService created"); - } - - int discoveredCount = discoveryService.discoverAddressCount(); - int currentCount = configHelper.getAddressCount(); - - if (discoveredCount > currentCount) { - LOGGER.log(Level.INFO, "[coin-" + currency + "] Address discovery found " + - discoveredCount + " addresses (was " + currentCount + ")"); - - // Update config and generate missing addresses - configHelper.setAddressCount(discoveredCount); - configHelper.writeConfig(); - - LOGGER.log(Level.INFO, "[coin-" + currency + "] Updated address count to " + - discoveredCount); - } else { - LOGGER.log(Level.INFO, "[coin-" + currency + "] No new addresses discovered, " + - "keeping current count: " + currentCount); - } - } - - public boolean isInstanceRunning() { - return updateFailures < 5; - } - - public static void setAddressDiscoveryEnabled(boolean enabled) { - addressDiscoveryEnabled = enabled; - } - - public static boolean isAddressDiscoveryEnabled() { - return addressDiscoveryEnabled; - } -} diff --git a/src/main/java/io/cloudchains/app/net/CoinTicker.java b/src/main/java/io/cloudchains/app/net/CoinTicker.java deleted file mode 100644 index 391cc84..0000000 --- a/src/main/java/io/cloudchains/app/net/CoinTicker.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.cloudchains.app.net; - -import java.util.Arrays; -import java.util.List; - -public enum CoinTicker { - BLOCKNET, - BLOCKNET_TESTNET5, - - BITCOIN, - BITCOIN_CASH, - LITECOIN, - DASHCOIN, - DIGIBYTE, - DOGECOIN, - TREZARCOIN, - SYSCOIN, - PIVX, - ALQOCOIN, - POLISCOIN, - PHORECOIN, - RAVENCOIN, - BITBAY, - UNOBTANIUM - ; - - /** - * List of supported coins. - * @return Supported coins - */ - public static List coins() { - return Arrays.asList( - BLOCKNET, - BLOCKNET_TESTNET5, - BITCOIN, -// BITCOIN_CASH, - not support on backend - LITECOIN, - DASHCOIN, -// DIGIBYTE, - not support on backend - DOGECOIN, -// TREZARCOIN, - not support on backend - SYSCOIN, - PIVX, - UNOBTANIUM -// ALQOCOIN, - not support on backend -// POLISCOIN, - not support on backend -// PHORECOIN, - not support on backend -// RAVENCOIN -// BITBAY - not support on backend - ); - } -} diff --git a/src/main/java/io/cloudchains/app/net/CoinTickerUtils.java b/src/main/java/io/cloudchains/app/net/CoinTickerUtils.java deleted file mode 100644 index bfe13af..0000000 --- a/src/main/java/io/cloudchains/app/net/CoinTickerUtils.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.cloudchains.app.net; - -import com.google.common.collect.HashBiMap; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -public class CoinTickerUtils { - private static HashBiMap tickers; - - static { - tickers = HashBiMap.create(); - - tickers.put(CoinTicker.BLOCKNET, "BLOCK"); - tickers.put(CoinTicker.BLOCKNET_TESTNET5, "TBLOCK"); - tickers.put(CoinTicker.BITCOIN, "BTC"); - tickers.put(CoinTicker.LITECOIN, "LTC"); - tickers.put(CoinTicker.DASHCOIN, "DASH"); - tickers.put(CoinTicker.DOGECOIN, "DOGE"); - tickers.put(CoinTicker.SYSCOIN, "SYS"); - tickers.put(CoinTicker.PIVX, "PIVX"); - - // TODO Temporarily disable until supported -// tickers.put(CoinTicker.DIGIBYTE, "DGB"); -// tickers.put(CoinTicker.BITCOIN_CASH, "BCH"); -// tickers.put(CoinTicker.RAVENCOIN, "RVN"); - - tickers.put(CoinTicker.ALQOCOIN, "XLQ"); - // TODO Temporarily disable PHORE and POLIS until supported -// tickers.put(CoinTicker.POLISCOIN, "POLIS"); -// tickers.put(CoinTicker.PHORECOIN, "PHR"); - tickers.put(CoinTicker.TREZARCOIN, "TZC"); - tickers.put(CoinTicker.BITBAY, "BAY"); - tickers.put(CoinTicker.UNOBTANIUM, "UNO"); - - } - - public static String tickerToString(CoinTicker ticker) { - return tickers.get(ticker); - } - - public static CoinTicker stringToTicker(String string) { - return tickers.inverse().get(string); - } - - public static Set getNetworkTickers() { - return new HashSet<>(Arrays.asList(CoinTicker.BLOCKNET, CoinTicker.BLOCKNET_TESTNET5)); - } - - public static CoinTicker[] getActiveTickers() { - return new CoinTicker[] { - CoinTicker.BLOCKNET, - CoinTicker.BITCOIN, - CoinTicker.LITECOIN, - CoinTicker.DASHCOIN, - CoinTicker.DOGECOIN, - CoinTicker.SYSCOIN, - CoinTicker.PIVX, - - // TODO Temporarily disable until supported -// CoinTicker.DIGIBYTE, -// CoinTicker.BITCOIN_CASH, -// CoinTicker.RAVENCOIN, - - CoinTicker.ALQOCOIN, - // TODO Temporarily disable PHORE and POLIS until supported -// CoinTicker.POLISCOIN, -// CoinTicker.PHORECOIN, - CoinTicker.TREZARCOIN, - CoinTicker.BITBAY, - CoinTicker.UNOBTANIUM, - }; - } - - public static boolean tickerExists(String string) { - return tickers.inverse().containsKey(string); - } - - public static boolean isActiveTicker(CoinTicker ticker) { - for (CoinTicker t : getActiveTickers()) { - if (ticker == t) { - return true; - } - } - return false; - } -} diff --git a/src/main/java/io/cloudchains/app/net/api/JSONRPCController.java b/src/main/java/io/cloudchains/app/net/api/JSONRPCController.java deleted file mode 100644 index 4cbfd06..0000000 --- a/src/main/java/io/cloudchains/app/net/api/JSONRPCController.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.cloudchains.app.net.api; - -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.util.ConfigHelper; - -import java.util.HashMap; - -public class JSONRPCController { - - private static final HashMap servers = new HashMap<>(); - private static JSONRPCMasterServer masterServer = new JSONRPCMasterServer(new ConfigHelper("master").getMasterRpcPort()); - - public static JSONRPCMasterServer getMasterServer() { - return masterServer; - } - - public static JSONRPCServer getRPCServer(CoinInstance coinInstance) { - if (coinInstance == null || coinInstance.getRPCPort() == -1) { - throw new IllegalArgumentException("Bad coin instance"); - } - - if (!servers.containsKey(coinInstance)) { - servers.put(coinInstance, new JSONRPCServer(coinInstance, coinInstance.getRPCPort())); - } - - return servers.get(coinInstance); - } - - public static void removeRPCServer(CoinInstance coinInstance) { - if (coinInstance == null || coinInstance.getRPCPort() == -1) { - throw new IllegalArgumentException("Bad coin instance"); - } - - JSONRPCServer server = servers.get(coinInstance); - if (server == null) - return; - - if (server.isAlive()) - server.deinit(); - - servers.remove(coinInstance); - } -} diff --git a/src/main/java/io/cloudchains/app/net/api/JSONRPCMasterServer.java b/src/main/java/io/cloudchains/app/net/api/JSONRPCMasterServer.java deleted file mode 100644 index a301328..0000000 --- a/src/main/java/io/cloudchains/app/net/api/JSONRPCMasterServer.java +++ /dev/null @@ -1,64 +0,0 @@ -package io.cloudchains.app.net.api; - -import io.cloudchains.app.App; -import io.cloudchains.app.net.api.http.master.HTTPServerInitializer; -import io.netty.bootstrap.ServerBootstrap; -import io.netty.buffer.PooledByteBufAllocator; -import io.netty.channel.Channel; -import io.netty.channel.ChannelOption; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.nio.NioServerSocketChannel; - -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class JSONRPCMasterServer extends Thread { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private final int port; - private boolean stopping = false; - - private Channel channel; - - JSONRPCMasterServer(int port) { - this.port = port; - } - - public void run() { - EventLoopGroup workerGroup = new NioEventLoopGroup(2); - try { - LOGGER.log(Level.INFO, "[rpc] Starting master RPC server on port " + port + "."); - - ServerBootstrap bootstrap = new ServerBootstrap(); - bootstrap.group(workerGroup) - .option(ChannelOption.SO_BACKLOG, 128) - .option(ChannelOption.SO_REUSEADDR, true) - .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) - .channel(NioServerSocketChannel.class) - .childHandler(new HTTPServerInitializer()); - - channel = bootstrap.bind(port).sync().channel(); - - channel.closeFuture().sync(); - } catch (Exception e) { - if (!stopping) { - LOGGER.log(Level.FINER, "[json-rpc-server] ERROR: Error during server operation! (master RPC)"); - e.printStackTrace(); - } - } - } - - public void deinit() { - stopping = true; - LOGGER.log(Level.FINER, "[json-rpc-server] Interrupting server."); - - if (channel != null && channel.isOpen()) { - channel.close(); - } else { - LOGGER.log(Level.FINER, "[json-rpc-server] Channel is null or not open during deinitialization."); - } - } -} diff --git a/src/main/java/io/cloudchains/app/net/api/JSONRPCServer.java b/src/main/java/io/cloudchains/app/net/api/JSONRPCServer.java deleted file mode 100644 index 4469092..0000000 --- a/src/main/java/io/cloudchains/app/net/api/JSONRPCServer.java +++ /dev/null @@ -1,63 +0,0 @@ -package io.cloudchains.app.net.api; - -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.api.http.server.HTTPServerInitializer; -import io.netty.bootstrap.ServerBootstrap; -import io.netty.buffer.PooledByteBufAllocator; -import io.netty.channel.Channel; -import io.netty.channel.ChannelOption; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.nio.NioServerSocketChannel; - -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class JSONRPCServer extends Thread { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private final CoinInstance coin; - private final int port; - private boolean stopping = false; - - private Channel channel; - - JSONRPCServer(CoinInstance coin, int port) { - this.coin = coin; - this.port = port; - } - - public void run() { - EventLoopGroup workerGroup = new NioEventLoopGroup(5); - try { - ServerBootstrap bootstrap = new ServerBootstrap(); - bootstrap.group(workerGroup) - .option(ChannelOption.SO_BACKLOG, 128) - .option(ChannelOption.SO_REUSEADDR, true) - .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) - .channel(NioServerSocketChannel.class) - .childHandler(new HTTPServerInitializer(coin)); - - channel = bootstrap.bind(port).sync().channel(); - - LOGGER.log(Level.FINER, "[rpc] Starting RPC server for " + CoinTickerUtils.tickerToString(coin.getTicker()) + " on port " + port + "."); - - channel.closeFuture().sync(); - } catch (Exception e) { - if (!stopping) { - LOGGER.log(Level.FINER, "[json-rpc-server] ERROR: Error during server operation! (" + CoinTickerUtils.tickerToString(coin.getTicker()) + ")"); - e.printStackTrace(); - } - } - } - - public void deinit() { - stopping = true; - LOGGER.log(Level.FINER, "[json-rpc-server] Interrupting server."); - - channel.close(); - } -} diff --git a/src/main/java/io/cloudchains/app/net/api/http/client/EXRServer.java b/src/main/java/io/cloudchains/app/net/api/http/client/EXRServer.java deleted file mode 100644 index a0b581a..0000000 --- a/src/main/java/io/cloudchains/app/net/api/http/client/EXRServer.java +++ /dev/null @@ -1,145 +0,0 @@ -package io.cloudchains.app.net.api.http.client; - -import io.cloudchains.app.net.CoinTicker; -import org.apache.http.client.config.RequestConfig; -import java.io.IOException; -import java.net.URI; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class EXRServer { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private final String endpoint; - private final EXRWrapper wrapper; - private volatile boolean healthy; - private long lastHealthCheck; - private final java.util.Set supportedCoins; - private volatile boolean capabilitiesProbed; - - // Add constants for configuration - private static final int HEALTH_CHECK_INTERVAL_MS = 5000; - private static final int CAPABILITY_PROBE_TIMEOUT_MS = 30000; - - public EXRServer(String endpoint) { - // Store endpoint with trailing slash for consistency - if (endpoint.endsWith("/")) { - this.endpoint = endpoint; - } else { - this.endpoint = endpoint + "/"; - } - - // EXRWrapper expects endpoint without trailing slash - String wrapperEndpoint = this.endpoint.endsWith("/") ? - this.endpoint.substring(0, this.endpoint.length() - 1) : this.endpoint; - - this.wrapper = new EXRWrapper(wrapperEndpoint); - this.healthy = true; - this.lastHealthCheck = 0; - this.supportedCoins = new java.util.HashSet<>(); - this.capabilitiesProbed = false; - } - - public boolean probeCapabilities() { - if (capabilitiesProbed) { - return true; - } - - if (!isHealthy()) { - return false; - } - - try { - // CALL HEIGHTS ONCE - not per coin - com.google.gson.JsonObject result = wrapper.executeGet("heights"); - if (result != null && result.has("result")) { - com.google.gson.JsonObject heights = result.getAsJsonObject("result"); - - // Extract ALL supported coins from single response - // Only include coins that have non-null values (null means not supported) - for (String coinName : heights.keySet()) { - com.google.gson.JsonElement heightValue = heights.get(coinName); - - if (heightValue.isJsonNull()) { - continue; // Skip unsupported coins (null values) - } - - try { - io.cloudchains.app.net.CoinTicker coin = io.cloudchains.app.net.CoinTickerUtils.stringToTicker(coinName); - if (coin != null) { - supportedCoins.add(coin); - } - } catch (Exception e) { - LOGGER.log(Level.FINER, "[exr-server] Failed to map coin " + coinName, e); - } - } - } - - capabilitiesProbed = true; - LOGGER.log(Level.INFO, "[exr-server] Probed capabilities for " + endpoint + ", supports: " + supportedCoins.size() + " coins: " + - supportedCoins.stream().map(io.cloudchains.app.net.CoinTickerUtils::tickerToString) - .reduce((a, b) -> a + ", " + b).orElse("none")); - return !supportedCoins.isEmpty(); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "[exr-server] Failed to probe capabilities for " + endpoint, e); - return false; - } - } - - public boolean isHealthy() { - long now = System.currentTimeMillis(); - if (now - lastHealthCheck < HEALTH_CHECK_INTERVAL_MS) { - return healthy; - } - - try { - com.google.gson.JsonObject result = wrapper.executeGet("heights"); - healthy = result != null && !result.isJsonNull(); - } catch (Exception e) { - healthy = false; - LOGGER.log(Level.WARNING, "[exr-server] Health check failed for " + endpoint, e); - } - lastHealthCheck = now; - return healthy; - } - - public com.google.gson.JsonObject execute(String method, List params) { - if (!isHealthy()) { - return null; - } - - // Server selection should have already filtered by coin support - // Remove redundant capability check to avoid race conditions - return wrapper.execute(method, params); - } - - public com.google.gson.JsonObject executeGet(String method) { - return isHealthy() ? wrapper.executeGet(method) : null; - } - - public void close() { - wrapper.close(); - } - - public String getEndpoint() { - return endpoint; - } - - public boolean isCapabilitiesProbed() { - return capabilitiesProbed; - } - - public java.util.Set getSupportedCoins() { - return new java.util.HashSet<>(supportedCoins); - } - - public boolean hasCapability(io.cloudchains.app.net.CoinTicker coin) { - return supportedCoins.contains(coin); - } -} \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/net/api/http/client/EXRServerPool.java b/src/main/java/io/cloudchains/app/net/api/http/client/EXRServerPool.java deleted file mode 100644 index a9b2dad..0000000 --- a/src/main/java/io/cloudchains/app/net/api/http/client/EXRServerPool.java +++ /dev/null @@ -1,270 +0,0 @@ -package io.cloudchains.app.net.api.http.client; - -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.net.CoinTickerUtils; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class EXRServerPool { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private final List servers; - private final AtomicInteger currentIndex; - private final Map> coinToServersMap; - private final Map endpointToServerMap; - private volatile boolean capabilitiesProbed; - - // Add synchronization lock for thread-safe map updates - private final Object mapUpdateLock = new Object(); - - public EXRServerPool(String endpoints) { - this.servers = new CopyOnWriteArrayList<>(); - this.currentIndex = new AtomicInteger(0); - this.coinToServersMap = new ConcurrentHashMap<>(); - this.endpointToServerMap = new ConcurrentHashMap<>(); - this.capabilitiesProbed = false; - - initializeServers(endpoints); - } - - // Add constants for configuration - private static final int CAPABILITY_PROBE_TIMEOUT_MS = 30000; - - public void startCapabilityProbing() { - if (capabilitiesProbed || servers.isEmpty()) { - return; - } - - LOGGER.log(Level.INFO, "[exr-pool] Starting capability probing for " + servers.size() + " servers"); - - // Start capability probing in background - new Thread(this::probeAllCapabilities, "EXR-Capability-Prober").start(); - } - - public void probeAllCapabilities() { - if (capabilitiesProbed) { - return; - } - - LOGGER.log(Level.INFO, "[exr-pool] Starting capability probing for " + servers.size() + " servers"); - - // Probe each server concurrently - List probeThreads = new ArrayList<>(); - for (EXRServer server : servers) { - Thread t = new Thread(() -> { - try { - server.probeCapabilities(); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "[exr-pool] Failed to probe server " + server.getEndpoint(), e); - } - }); - probeThreads.add(t); - t.start(); - } - - // Wait for all probes to complete - for (Thread t : probeThreads) { - try { - t.join(CAPABILITY_PROBE_TIMEOUT_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOGGER.log(Level.WARNING, "[exr-pool] Capability probing interrupted", e); - } - } - - // Build coin-to-servers mapping with synchronization - synchronized (mapUpdateLock) { - coinToServersMap.clear(); - for (EXRServer server : servers) { - for (io.cloudchains.app.net.CoinTicker coin : server.getSupportedCoins()) { - coinToServersMap.computeIfAbsent(coin, k -> new ArrayList<>()).add(server); - } - } - } - - capabilitiesProbed = true; - logCapabilityResults(); - LOGGER.log(Level.INFO, "[exr-pool] Capability probing completed"); - } - - private void logCapabilityResults() { - LOGGER.log(Level.INFO, "[exr-pool] === EXR Server Capabilities ==="); - for (EXRServer server : servers) { - if (server.isCapabilitiesProbed()) { - String supportedCoins = server.getSupportedCoins().stream() - .map(coin -> io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)) - .reduce((a, b) -> a + ", " + b).orElse("none"); - LOGGER.log(Level.INFO, "[exr-pool] " + server.getEndpoint() + " supports: " + supportedCoins); - } else { - LOGGER.log(Level.WARNING, "[exr-pool] " + server.getEndpoint() + " capability probe failed"); - } - } - - LOGGER.log(Level.INFO, "[exr-pool] === Coin Distribution ==="); - for (io.cloudchains.app.net.CoinTicker coin : io.cloudchains.app.net.CoinTicker.coins()) { - List supportingServers = coinToServersMap.get(coin); - if (supportingServers != null && !supportingServers.isEmpty()) { - LOGGER.log(Level.INFO, "[exr-pool] " + io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin) + " supported by " + supportingServers.size() + " servers"); - } - } - } - - private void initializeServers(String endpoints) { - if (endpoints == null || endpoints.trim().isEmpty()) { - return; - } - - String[] endpointArray = endpoints.split(","); - for (String endpoint : endpointArray) { - String trimmed = endpoint.trim(); - if (!trimmed.isEmpty()) { - EXRServer server = new EXRServer(trimmed); - servers.add(server); - endpointToServerMap.put(trimmed, server); - LOGGER.log(Level.INFO, "[exr-pool] Added EXR server: " + trimmed); - } - } - - if (!servers.isEmpty()) { - LOGGER.log(Level.INFO, "[exr-pool] Created pool with " + servers.size() + " servers"); - } - } - - public EXRServer selectServer() { - if (servers.isEmpty()) { - LOGGER.log(Level.WARNING, "[exr-pool] No servers available for selection"); - return null; - } - - // Try round-robin through healthy servers - int start = currentIndex.getAndIncrement() % servers.size(); - for (int i = 0; i < servers.size(); i++) { - int index = (start + i) % servers.size(); - EXRServer server = servers.get(index); - if (server.isHealthy()) { - LOGGER.log(Level.FINE, "[exr-pool] Selected server: " + server.getEndpoint()); - return server; - } - } - LOGGER.log(Level.WARNING, "[exr-pool] No healthy servers available"); - return null; // All servers unhealthy - } - - /** - * Extract health filtering logic - * @param supportingServers List of servers that support the coin - * @return List of healthy servers that support the coin - */ - private List getHealthySupportingServers(List supportingServers) { - List healthyServers = new ArrayList<>(); - for (EXRServer server : supportingServers) { - if (server.isHealthy()) { - healthyServers.add(server); - } - } - return healthyServers; - } - - /** - * Extract server selection logic - * @param healthyServers List of healthy servers - * @param coin The coin to select a server for - * @return Selected server or null if none available - */ - private EXRServer selectFromHealthyServers(List healthyServers, io.cloudchains.app.net.CoinTicker coin) { - if (healthyServers.isEmpty()) { - LOGGER.log(Level.SEVERE, "[exr-pool] NO HEALTHY EXR SERVERS FOR COIN: " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - return null; - } - - int index = currentIndex.getAndIncrement() % healthyServers.size(); - EXRServer selectedServer = healthyServers.get(index); - - // Double-check that the selected server actually supports the coin - if (!selectedServer.hasCapability(coin)) { - LOGGER.log(Level.SEVERE, "[exr-pool] CRITICAL ERROR: Selected server " + - selectedServer.getEndpoint() + " does NOT support coin " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - return null; - } - - return selectedServer; - } - - /** - * Select a server for a specific coin with proper error handling - * @param coin The coin to select a server for - * @return Selected server or null if none available - */ - public EXRServer selectServerForCoin(io.cloudchains.app.net.CoinTicker coin) { - if (!capabilitiesProbed) { - return null; // Wait for probing to complete - } - - List supportingServers = coinToServersMap.get(coin); - if (supportingServers == null || supportingServers.isEmpty()) { - LOGGER.log(Level.SEVERE, "[exr-pool] NO EXR SERVERS SUPPORT COIN: " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - return null; // FAIL - NO FALLBACK TO BASE_URL - } - - List healthyServers = getHealthySupportingServers(supportingServers); - return selectFromHealthyServers(healthyServers, coin); - } - - - public Set getSupportedCoins() { - Set supported = new HashSet<>(); - for (EXRServer server : servers) { - supported.addAll(server.getSupportedCoins()); - } - return supported; - } - - public boolean hasServerForCoin(io.cloudchains.app.net.CoinTicker coin) { - if (!capabilitiesProbed) { - return !servers.isEmpty(); // Assume at least one server supports it - } - - List supportingServers = coinToServersMap.get(coin); - return supportingServers != null && !supportingServers.isEmpty(); - } - - public void close() { - for (EXRServer server : servers) { - server.close(); - } - servers.clear(); - coinToServersMap.clear(); - endpointToServerMap.clear(); - } - - public List getServers() { - return new ArrayList<>(servers); - } - - public boolean isCapabilitiesProbed() { - return capabilitiesProbed; - } - - public int getServerCount() { - return servers.size(); - } - - public String getStatus() { - int healthyCount = 0; - for (EXRServer server : servers) { - if (server.isHealthy()) { - healthyCount++; - } - } - return healthyCount + "/" + servers.size() + " servers healthy"; - } -} \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/net/api/http/client/EXRWrapper.java b/src/main/java/io/cloudchains/app/net/api/http/client/EXRWrapper.java deleted file mode 100644 index 4c5d4ff..0000000 --- a/src/main/java/io/cloudchains/app/net/api/http/client/EXRWrapper.java +++ /dev/null @@ -1,159 +0,0 @@ -package io.cloudchains.app.net.api.http.client; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.util.EntityUtils; -import org.apache.http.client.config.RequestConfig; - -import java.io.IOException; -import java.net.URI; -import java.util.List; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class EXRWrapper { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private final String exrEndpoint; - private final CloseableHttpClient client; - private final Gson gson; - - // Constants for configuration - private static final int HTTP_TIMEOUT_MS = 30000; - private static final String LOG_TAG = "[exr]"; - - public EXRWrapper(String exrEndpoint) { - this.exrEndpoint = exrEndpoint; - this.gson = new Gson(); - - // Configure HTTP client with timeouts - RequestConfig config = RequestConfig.custom() - .setConnectTimeout(30000) - .setConnectionRequestTimeout(30000) - .setSocketTimeout(30000) - .build(); - - this.client = HttpClients.custom() - .setDefaultRequestConfig(config) - .build(); - } - - /** - * Execute an HTTP request and return the response body. - * @param request The HTTP request to execute - * @param operation Description of the operation for logging - * @return Response body string or null on error - */ - private String executeHttpRequest(org.apache.http.client.methods.HttpRequestBase request, String operation) { - org.apache.http.HttpResponse response = null; - try { - response = client.execute(request); - if (validateResponse(response)) { - org.apache.http.HttpEntity entity = response.getEntity(); - String responseBody = org.apache.http.util.EntityUtils.toString(entity); - org.apache.http.util.EntityUtils.consume(entity); - return responseBody; - } else { - LOGGER.log(Level.WARNING, LOG_TAG + " " + operation + " failed for endpoint: " + exrEndpoint); - return null; - } - } catch (java.io.IOException e) { - LOGGER.log(Level.WARNING, LOG_TAG + " " + operation + " failed for endpoint: " + exrEndpoint, e); - return null; - } finally { - request.reset(); - - } - } - - /** - * Process response JSON and handle wrapping for different response types. - * @param responseBody The raw response body - * @return Processed JsonObject with proper wrapping - */ - private JsonObject processResponse(String responseBody) { - JsonElement responseElement = gson.fromJson(responseBody, JsonElement.class); - - if (responseElement.isJsonObject()) { - return responseElement.getAsJsonObject(); - } else { - // Wrap non-objects (arrays, primitives) in a result field - JsonObject wrapperObj = new JsonObject(); - wrapperObj.add("result", responseElement); - return wrapperObj; - } - } - - /** - * Execute a POST request to an EXR endpoint. - * Transforms the method and params into EXR format. - * - * @param method The method name (e.g., "getblockhash") - * @param params The parameters as a List of Objects - * @return JsonObject response or null on error - */ - public com.google.gson.JsonObject execute(String method, List params) { - String endpoint = exrEndpoint + "/xrs/" + method; - String currency = params.isEmpty() || !(params.get(0) instanceof String) ? - "unknown" : (String) params.get(0); - - String requestBody = gson.toJson(params); - HttpPost httpPost = new HttpPost(); - httpPost.setURI(URI.create(endpoint)); - httpPost.setHeader("Content-Type", "application/json"); - - try { - httpPost.setEntity(new StringEntity(requestBody)); - String responseBody = executeHttpRequest(httpPost, "execute POST for " + method + " " + currency); - return responseBody != null ? processResponse(responseBody) : null; - } catch (java.io.IOException e) { - LOGGER.log(Level.WARNING, LOG_TAG + " execute POST failed for " + method + " " + currency + " endpoint: " + endpoint, e); - return null; - } finally { - httpPost.reset(); - } - } - - /** - * Execute a GET request to an EXR endpoint. - * - * @param method The method name (e.g., "fees", "heights") - * @return JsonObject response or null on error - */ - public com.google.gson.JsonObject executeGet(String method) { - String endpoint = exrEndpoint + "/xrs/" + method; - HttpGet httpGet = new HttpGet(endpoint); - httpGet.setHeader("Content-Type", "application/json"); - - String responseBody = executeHttpRequest(httpGet, "execute GET for method " + method); - return responseBody != null ? processResponse(responseBody) : null; - } - - /** - * Close the HTTP client resources. - */ - public void close() { - try { - client.close(); - } catch (IOException e) { - LOGGER.log(Level.WARNING, LOG_TAG + " Failed to close HTTP client", e); - } - } - - private boolean validateResponse(HttpResponse response) { - return response.getStatusLine().getStatusCode() == 200 && - response.getEntity() != null && - response.getEntity().getContentLength() != 0; - } -} \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/net/api/http/master/HTTPServerHandler.java b/src/main/java/io/cloudchains/app/net/api/http/master/HTTPServerHandler.java deleted file mode 100644 index def902b..0000000 --- a/src/main/java/io/cloudchains/app/net/api/http/master/HTTPServerHandler.java +++ /dev/null @@ -1,348 +0,0 @@ -package io.cloudchains.app.net.api.http.master; - -import com.google.common.base.Preconditions; -import com.google.gson.*; -import com.subgraph.orchid.encoders.Base64; -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.App; -import io.cloudchains.app.Version; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.api.http.client.HTTPClient; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import io.cloudchains.app.util.AddressBalance; -import io.cloudchains.app.util.ConfigHelper; -import io.cloudchains.app.util.UTXO; -import io.cloudchains.app.wallet.WalletHelper; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; -import io.netty.handler.codec.http.*; -import io.netty.util.CharsetUtil; -import org.bitcoinj.core.*; -import org.bitcoinj.script.Script; -import org.bitcoinj.script.ScriptBuilder; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.security.SignatureException; -import java.util.Arrays; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class HTTPServerHandler extends SimpleChannelInboundHandler { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private ConfigHelper configHelper; - - HTTPServerHandler() { - configHelper = new ConfigHelper("master"); - - if (configHelper.getRpcUsername().isEmpty() && configHelper.getRpcPassword().isEmpty()) { - configHelper.setRpcUsername(generateRandomString(12)); - configHelper.setRpcPassword(generateRandomString(32)); - - configHelper.writeConfig(); - } - } - - private String generateRandomString(int length) { - SecureRandom secureRandom = new SecureRandom(); - - byte[] token = new byte[length]; - secureRandom.nextBytes(token); - - return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(token); - } - - @Override - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - super.channelInactive(ctx); - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - cause.printStackTrace(); - - FullHttpResponse httpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST); - writeResponse(ctx, httpResponse, null); - ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - ctx.close(); - } - - @Override - public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { - LOGGER.log(Level.FINER, "[http-server-handler] DEBUG: Channel read complete. Flushing context."); - super.channelReadComplete(ctx); - ctx.flush(); - } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) { - boolean successfulAuth = false; - HttpResponseStatus status = HttpResponseStatus.OK; - JsonObject response = new JsonObject(); - - if (request != null) { - HttpHeaders httpHeaders = request.headers(); - - if (HttpUtil.is100ContinueExpected(request)) { - send100Continue(ctx); - } - - String headerUser; - String headerPass; - if (httpHeaders.contains("Authorization")) { - String authorization = httpHeaders.get("Authorization"); - - if (authorization != null && authorization.toLowerCase().startsWith("basic")) { - String base64Credentials = authorization.substring("Basic".length()).trim(); - byte[] credDecoded = Base64.decode(base64Credentials); - String credentials = new String(credDecoded, StandardCharsets.UTF_8); - final String[] values = credentials.split(":", 2); - - headerUser = values[0]; - headerPass = values[1]; - - if (headerUser.equals(configHelper.getRpcUsername()) && headerPass.equals(configHelper.getRpcPassword())) { - successfulAuth = true; - LOGGER.log(Level.FINER, "[http-server-handler] Successful Auth"); - } - } - } - - if (!request.uri().equals("/")) { - JsonObject onlyServerRootJSON = new JsonObject(); - onlyServerRootJSON.addProperty("code", -1002); - onlyServerRootJSON.addProperty("message", "Only the server root ('/') is being served."); - - response.add("error", onlyServerRootJSON); - response.add("result", JsonNull.INSTANCE); - status = HttpResponseStatus.BAD_REQUEST; - } - - if (request.method() != HttpMethod.POST) { - JsonObject onlyPostAllowedJSON = new JsonObject(); - - onlyPostAllowedJSON.addProperty("code", -1003); - onlyPostAllowedJSON.addProperty("message", "Only HTTP POST is accepted."); - - response.add("error", onlyPostAllowedJSON); - response.add("result", JsonNull.INSTANCE); - status = HttpResponseStatus.BAD_REQUEST; - } - - if (!successfulAuth) { - JsonObject onlyServerRootJSON = new JsonObject(); - onlyServerRootJSON.addProperty("code", -1111); - onlyServerRootJSON.addProperty("message", "Unauthorized!"); - - response.add("error", onlyServerRootJSON); - response.add("result", JsonNull.INSTANCE); - status = HttpResponseStatus.FORBIDDEN; - } - } - - if (status != HttpResponseStatus.OK) { - ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); - FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); - - writeResponse(ctx, httpResponse, request); - ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - return; - } - - if (request != null) { - String content = request.content().toString(CharsetUtil.UTF_8); - JsonObject jsonReq = null; - - try { - jsonReq = new JsonParser().parse(content).getAsJsonObject(); - - Preconditions.checkNotNull(jsonReq); - - if (!jsonReq.has("method") || !jsonReq.has("params")) { - ctx.close(); - throw new IllegalArgumentException("Bad JSON-RPC request by client."); - } - } catch (Exception e) { - LOGGER.log(Level.INFO, "Failed Content: " + content); - e.printStackTrace(); - JsonObject errorParsingJSON = new JsonObject(); - errorParsingJSON.addProperty("code", -1001); - errorParsingJSON.addProperty("message", "Error parsing JSON."); - - response.add("error", errorParsingJSON); - response.add("result", JsonNull.INSTANCE); - if (e instanceof IllegalArgumentException) { - LOGGER.log(Level.FINER, "[http-server-handler] WARNING: Client sent valid JSON, but did not specify method and/or parameters!"); - } else { - LOGGER.log(Level.FINER, "[http-server-handler] WARNING: Client sent invalid JSON!"); - } - status = HttpResponseStatus.BAD_REQUEST; - } - - if (status == HttpResponseStatus.OK) { - Preconditions.checkNotNull(jsonReq); - - String method = jsonReq.get("method").getAsString(); - JsonArray params = jsonReq.get("params").getAsJsonArray(); - - LOGGER.log(Level.INFO, "[http-server-handler] RPC CALL: " + method + " PARAMS: " + params.size()); - for (int i = 0; i < params.size(); i++) { - LOGGER.log(Level.INFO, "[http-server-handler] PARAM " + i + ": " + params.get(i).toString()); - } - - response = getResponse(method, params); - LOGGER.log(Level.FINER, response.toString()); - } else { - ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); - FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); - - writeResponse(ctx, httpResponse, request); - ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - return; - } - - if (request instanceof LastHttpContent) { - ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); - - FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); - - if (!writeResponse(ctx, httpResponse, request)) { - ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - } - } - } - } - - private JsonObject getResponse(String method, JsonArray params) { - JsonObject response = new JsonObject(); - boolean shutdownRequested = false; - - switch (method.toLowerCase()) { - case "reloadconfig": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: reloadconfig \n\ntoken (string, required)"); - - response.add("error", errorJSON); - break; - } - - CoinTicker ticker = CoinTickerUtils.stringToTicker(params.get(0).getAsString()); - CoinInstance instance = CoinInstance.getInstance(ticker); - - Runnable r = () -> { - try { - Thread.sleep(500); - instance.reloadConfig(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }; - new Thread(r).start(); - - response.addProperty("result", true); - response.add("error", JsonNull.INSTANCE); - break; - } - case "version": { - response.addProperty("result", Version.CLIENT_VERSION); - response.add("error", JsonNull.INSTANCE); - break; - } -// case "reloadconfigs": { -// boolean success = true; -// for (CoinInstance instance : CoinInstance.getCoinInstances()) { -// if (!CoinTickerUtils.isActiveTicker(instance.getTicker())) -// continue; -// -// try { -// LOGGER.log(Level.INFO, instance.getTicker().toString()); -// instance.reloadConfig(); -// } catch (Exception e) { -// success = false; -// e.printStackTrace(); -// } -// } -// -// response.addProperty("result", success); -// response.add("error", JsonNull.INSTANCE); -// break; -// } - case "help": { - String helpString = "Master JSON-RPC server\n" - + "This JSON-RPC server is served by " + CoinInstance.getVersionString() + "\n" - + "\n" - + "help - Display the help\n" - + "\n=====RPC Master=====\n" - + "stop - Shutdown the server\n" - + "reloadconfig - Reload configuration for specified token\n" - + "version - Get version\n"; -// + "reloadconfigs - Reload all configuration files\n"; - - response.addProperty("result", helpString); - response.add("error", JsonNull.INSTANCE); - break; - } - case "stop": { - shutdownRequested = true; - response.addProperty("result", "shutting down..."); - response.add("error", JsonNull.INSTANCE); - break; - } - default: { - JsonObject methodNotFound = new JsonObject(); - methodNotFound.addProperty("code", -32601); - methodNotFound.addProperty("message", "Method not found."); - response.add("error", methodNotFound); - response.add("result", JsonNull.INSTANCE); - break; - } - } - - if (shutdownRequested) { - (new Thread(() -> { - System.exit(0); - })).start(); // shutdown the server - } - return response; - } - - private boolean writeResponse(ChannelHandlerContext ctx, FullHttpResponse httpResponse, FullHttpRequest request) { - boolean keepAlive = false; - if (request != null) - keepAlive = HttpUtil.isKeepAlive(request); - - httpResponse.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json"); - httpResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, httpResponse.content().readableBytes()); - httpResponse.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - httpResponse.headers().set(HttpHeaderNames.SERVER, CoinInstance.getVersionString()); - - LOGGER.log(Level.FINER, "[http-server-handler] Writing response to channel. Keep alive? " + keepAlive); - LOGGER.log(Level.FINER, "[http-server-handler] Response content: " + httpResponse.content().toString(CharsetUtil.UTF_8)); - ctx.write(httpResponse); - - return keepAlive; - } - - private static void send100Continue(ChannelHandlerContext ctx) { - FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE); - ctx.write(response); - } -} diff --git a/src/main/java/io/cloudchains/app/net/api/http/master/HTTPServerInitializer.java b/src/main/java/io/cloudchains/app/net/api/http/master/HTTPServerInitializer.java deleted file mode 100644 index 806ea69..0000000 --- a/src/main/java/io/cloudchains/app/net/api/http/master/HTTPServerInitializer.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.cloudchains.app.net.api.http.master; - -import io.cloudchains.app.net.api.http.server.ExceptionHandler; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.socket.SocketChannel; -import io.netty.handler.codec.http.HttpObjectAggregator; -import io.netty.handler.codec.http.HttpRequestDecoder; -import io.netty.handler.codec.http.HttpResponseEncoder; -import io.netty.handler.timeout.ReadTimeoutHandler; -import io.netty.handler.timeout.WriteTimeoutHandler; - -public class HTTPServerInitializer extends ChannelInitializer { - public HTTPServerInitializer() {} - - @Override - protected void initChannel(SocketChannel ch) { - ChannelPipeline pipeline = ch.pipeline(); - - pipeline.addLast(new WriteTimeoutHandler(30)); - pipeline.addLast(new ReadTimeoutHandler(30)); - pipeline.addLast(new HttpRequestDecoder()); - pipeline.addLast(new HttpResponseEncoder()); - pipeline.addLast(new HttpObjectAggregator(100000000)); - pipeline.addLast(new HTTPServerHandler()); - pipeline.addLast(new ExceptionHandler()); - } - -} diff --git a/src/main/java/io/cloudchains/app/net/api/http/server/HTTPServerHandler.java b/src/main/java/io/cloudchains/app/net/api/http/server/HTTPServerHandler.java deleted file mode 100644 index 2c01d77..0000000 --- a/src/main/java/io/cloudchains/app/net/api/http/server/HTTPServerHandler.java +++ /dev/null @@ -1,1801 +0,0 @@ -package io.cloudchains.app.net.api.http.server; - -import com.google.common.base.Preconditions; -import com.google.gson.*; -import com.subgraph.orchid.encoders.Base64; -import com.subgraph.orchid.encoders.Hex; - -import io.cloudchains.app.App; -import io.cloudchains.app.Version; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.api.http.client.HTTPClient; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import io.cloudchains.app.util.AddressBalance; -import io.cloudchains.app.util.ConfigHelper; -import io.cloudchains.app.util.UTXO; -import io.cloudchains.app.util.Utility; -import io.cloudchains.app.wallet.WalletHelper; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; -import io.netty.handler.codec.http.*; -import io.netty.util.CharsetUtil; -import org.bitcoinj.core.*; -import org.bitcoinj.script.Script; -import org.bitcoinj.script.ScriptBuilder; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.SignatureException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -// Define a helper class for output entries -class OutputEntry { - public String address; - public double amount; - - public OutputEntry(String address, double amount) { - this.address = address; - this.amount = amount; - } -} - -public class HTTPServerHandler extends SimpleChannelInboundHandler { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private HTTPClient httpClient; - private CoinInstance coin; - private ConfigHelper configHelper; - - HTTPServerHandler(CoinInstance coin) { - this.coin = coin; - this.configHelper = coin.getConfigHelper(); - this.httpClient = new HTTPClient(5); - } - - @Override - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - super.channelInactive(ctx); - this.httpClient.close(); - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - cause.printStackTrace(); - - FullHttpResponse httpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST); - writeResponse(ctx, httpResponse, null); - ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - ctx.close(); - this.httpClient.close(); - } - - @Override - public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { - LOGGER.log(Level.FINER, "[http-server-handler] DEBUG: Channel read complete. Flushing context."); - super.channelReadComplete(ctx); - ctx.flush(); - } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) { - boolean successfulAuth = false; - HttpResponseStatus status = HttpResponseStatus.OK; - JsonObject response = new JsonObject(); - - if (request != null) { - HttpHeaders httpHeaders = request.headers(); - - if (HttpUtil.is100ContinueExpected(request)) { - send100Continue(ctx); - } - - String headerUser; - String headerPass; - if (httpHeaders.contains("Authorization")) { - String authorization = httpHeaders.get("Authorization"); - - if (authorization != null && authorization.toLowerCase().startsWith("basic")) { - String base64Credentials = authorization.substring("Basic".length()).trim(); - byte[] credDecoded = Base64.decode(base64Credentials); - String credentials = new String(credDecoded, StandardCharsets.UTF_8); - final String[] values = credentials.split(":", 2); - - headerUser = values[0]; - headerPass = values[1]; - - if (headerUser.equals(configHelper.getRpcUsername()) && headerPass.equals(configHelper.getRpcPassword())) { - successfulAuth = true; - LOGGER.log(Level.FINER, "[http-server-handler] Successful Auth"); - } - } - } - - if (!request.uri().equals("/")) { - JsonObject onlyServerRootJSON = new JsonObject(); - onlyServerRootJSON.addProperty("code", -1002); - onlyServerRootJSON.addProperty("message", "Only the server root ('/') is being served."); - - response.add("error", onlyServerRootJSON); - response.add("result", JsonNull.INSTANCE); - status = HttpResponseStatus.BAD_REQUEST; - } - - if (request.method() != HttpMethod.POST) { - JsonObject onlyPostAllowedJSON = new JsonObject(); - - onlyPostAllowedJSON.addProperty("code", -1003); - onlyPostAllowedJSON.addProperty("message", "Only HTTP POST is accepted."); - - response.add("error", onlyPostAllowedJSON); - response.add("result", JsonNull.INSTANCE); - status = HttpResponseStatus.BAD_REQUEST; - } - - if (!coin.isInstanceRunning()) { - JsonObject instanceNotRunning = new JsonObject(); - - instanceNotRunning.addProperty("code", -1112); - instanceNotRunning.addProperty("message", "This coin is temporarily unavailable."); - - response.add("error", instanceNotRunning); - response.add("result", JsonNull.INSTANCE); - status = HttpResponseStatus.SERVICE_UNAVAILABLE; - } - - if (!successfulAuth) { - JsonObject onlyServerRootJSON = new JsonObject(); - onlyServerRootJSON.addProperty("code", -1111); - onlyServerRootJSON.addProperty("message", "Unauthorized!"); - - response.add("error", onlyServerRootJSON); - response.add("result", JsonNull.INSTANCE); - status = HttpResponseStatus.FORBIDDEN; - } - } - - if (status != HttpResponseStatus.OK) { - ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); - FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); - - writeResponse(ctx, httpResponse, request); - ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - return; - } - - if (request != null) { - String content = request.content().toString(CharsetUtil.UTF_8); - JsonObject jsonReq = null; - - try { - jsonReq = new JsonParser().parse(content).getAsJsonObject(); - - Preconditions.checkNotNull(jsonReq); - - if (!jsonReq.has("method") || !jsonReq.has("params")) { - ctx.close(); - throw new IllegalArgumentException("Bad JSON-RPC request by client."); - } - } catch (Exception e) { - LOGGER.log(Level.INFO, "Failed Content: " + content); - e.printStackTrace(); - JsonObject errorParsingJSON = new JsonObject(); - errorParsingJSON.addProperty("code", -1001); - errorParsingJSON.addProperty("message", "Error parsing JSON."); - - response.add("error", errorParsingJSON); - response.add("result", JsonNull.INSTANCE); - if (e instanceof IllegalArgumentException) { - LOGGER.log(Level.FINER, "[http-server-handler] WARNING: Client sent valid JSON, but did not specify method and/or parameters!"); - } else { - LOGGER.log(Level.FINER, "[http-server-handler] WARNING: Client sent invalid JSON!"); - } - status = HttpResponseStatus.BAD_REQUEST; - } - - if (status == HttpResponseStatus.OK) { - Preconditions.checkNotNull(jsonReq); - - String method = jsonReq.get("method").getAsString(); - JsonArray params = jsonReq.get("params").getAsJsonArray(); - - LOGGER.log(Level.INFO, "[http-server-handler] RPC CALL: " + coin.getTicker()+ " " + method + " PARAMS: " + params.size()); - for (int i = 0; i < params.size(); i++) { - LOGGER.log(Level.INFO, "[http-server-handler] PARAM " + i + ": " + params.get(i).toString()); - } - - response = getResponse(method, params); - LOGGER.log(Level.FINER, response.toString()); - } else { - ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); - FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); - - writeResponse(ctx, httpResponse, request); - ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - return; - } - - if (request instanceof LastHttpContent) { - ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); - - FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); - - if (!writeResponse(ctx, httpResponse, request)) { - ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); - } - } - } - } - - private JsonObject getResponse(String method, JsonArray params) { - JsonObject response = new JsonObject(); - - switch (method.toLowerCase()) { - case "reloadconfig": { - Runnable r = () -> { - try { - Thread.sleep(500); - } catch (InterruptedException e) { - e.printStackTrace(); - } - - coin.reloadConfig(); - }; - new Thread(r).start(); - - response.addProperty("result", true); - response.add("error", JsonNull.INSTANCE); - break; - } - case "getinfo": { - JsonObject infoJSON = new JsonObject(); - - infoJSON.addProperty("protocolversion", coin.getNetworkParameters().getProtocolVersionNum(NetworkParameters.ProtocolVersion.CURRENT)); - infoJSON.addProperty("ticker", CoinTickerUtils.tickerToString(coin.getTicker())); - infoJSON.addProperty("balance", coin.getAllBalances()); - infoJSON.addProperty("testnet", coin.isTestnet()); - infoJSON.addProperty("difficulty", 0.0); - infoJSON.addProperty("connections", 1); // We make this a static value for now, since we are deprecating P2P - infoJSON.addProperty("blocks", CoinInstance.getBlockCountByTicker(coin.getTicker())); - infoJSON.addProperty("keypoolsize", coin.getAddressKeyPairs().size()); - infoJSON.addProperty("keypoololdest", 0.0); - - BigDecimal relayFeeDecimal = new BigDecimal(coin.getConfigHelper().getFee()).setScale(8, BigDecimal.ROUND_DOWN); - infoJSON.addProperty("relayfee", relayFeeDecimal); - - infoJSON.addProperty("networkactive", true); - infoJSON.addProperty("timeoffset", 0); - infoJSON.addProperty("rpcready", coin.isInstanceRunning()); - - response.add("result", infoJSON); - response.add("error", JsonNull.INSTANCE); - break; - } - case "getblockcount": { - response.addProperty("result", CoinInstance.getBlockCountByTicker(coin.getTicker())); - response.add("error", JsonNull.INSTANCE); - break; - } - case "getnetworkinfo": { - JsonObject networkInfoJSON = new JsonObject(); - networkInfoJSON.addProperty("protocolversion", coin.getNetworkParameters().getProtocolVersionNum(NetworkParameters.ProtocolVersion.CURRENT)); - networkInfoJSON.addProperty("ticker", CoinTickerUtils.tickerToString(coin.getTicker())); - networkInfoJSON.addProperty("subversion", CoinInstance.getVersionString()); - networkInfoJSON.addProperty("connections", 1); - networkInfoJSON.addProperty("localservices", "0000000000000000"); - - double relayFee = CoinInstance.getRelayFeeByTicker(coin.getTicker()); - if (relayFee == -1) { - relayFee = coin.getConfigHelper().getFee(); - } - - BigDecimal relayFeeDecimal = BigDecimal.valueOf(relayFee).setScale(8, BigDecimal.ROUND_DOWN); - - networkInfoJSON.addProperty("relayfee", relayFeeDecimal); - - response.add("result", networkInfoJSON); - response.add("error", JsonNull.INSTANCE); - break; - } - case "listunspent": { - JsonArray unspent = httpClient.getUtxos(coin.getTicker(), 30000); - if (unspent == null) { - response.add("result", JsonNull.INSTANCE); - JsonObject err = new JsonObject(); - err.addProperty("message", "listunspent not ready"); - err.addProperty("code", 3002); - response.add("error", err); - break; - } - response.add("result", unspent); - response.add("error", JsonNull.INSTANCE); - break; - } - case "listtransactions": { - int startTime = 0; - int endTime = 0; - if (params.size() == 2) { - startTime = params.get(0).getAsInt(); - endTime = params.get(1).getAsInt(); - } - JsonArray transactions = httpClient.getHistory(coin.getTicker(), startTime, endTime, 60 * 15); - if (transactions == null) { - response.add("result", JsonNull.INSTANCE); - JsonObject err = new JsonObject(); - err.addProperty("message", "listtransactions not ready"); - err.addProperty("code", 3002); - response.add("error", err); - break; - } - response.add("result", transactions); - response.add("error", JsonNull.INSTANCE); - break; - } - case "getblockchaininfo": { - JsonObject blockchainInfoJSON = new JsonObject(); - - blockchainInfoJSON.addProperty("chain", coin.getNetworkParameters().getPaymentProtocolId()); - blockchainInfoJSON.addProperty("blocks", CoinInstance.getBlockCountByTicker(coin.getTicker())); - blockchainInfoJSON.addProperty("headers", CoinInstance.getBlockCountByTicker(coin.getTicker())); - blockchainInfoJSON.addProperty("verificationprogress", 1.0); - blockchainInfoJSON.addProperty("difficulty", 0.0); - - blockchainInfoJSON.addProperty("initialblockdownload", false); - blockchainInfoJSON.addProperty("pruned", false); - - response.add("result", blockchainInfoJSON); - response.add("error", JsonNull.INSTANCE); - break; - } - case "getblockhash": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: getblockhash index\n\nindex (num, required)"); - - response.add("error", errorJSON); - break; - } - - int blockIndex = params.get(0).getAsInt(); - JsonObject blockHash = httpClient.getBlockHash(coin.getTicker(), blockIndex); - - if (blockHash == null || blockHash.has("error") && !blockHash.get("error").isJsonNull()) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Error obtaining blockhash!"); - response.add("error", errorJSON); - - break; - } - - if (blockHash.has("result")) { - response.add("result", blockHash.get("result")); - response.add("error", JsonNull.INSTANCE); - - break; - } - - response.add("result", blockHash); - response.add("error", JsonNull.INSTANCE); - break; - } - case "sendrawtransaction": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: sendrawtransaction hex-tx\n\nhex-tx (string, required) - Raw transaction, hex encoded"); - - response.add("error", errorJSON); - break; - } - - String rawTx; - Transaction transaction; - try { - rawTx = params.get(0).getAsString(); - transaction = new Transaction(coin.getNetworkParameters(), Hex.decode(rawTx)); - WalletHelper.setAsSpent(coin.getTicker(), transaction, true); - } catch (JsonParseException e) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Error parsing JSON!"); - response.add("error", errorJSON); - - e.printStackTrace(); - break; - } - - JsonObject txid = httpClient.sendRawTransaction(coin.getTicker(), rawTx); - if (txid == null || txid.has("error") && !txid.get("error").isJsonNull()) { - int code = -1; - - if (txid != null) - code = txid.get("error").getAsInt(); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", code); - errorJSON.addProperty("message", "Error sending transaction!"); - response.add("error", errorJSON); - - break; - } - - if (txid.has("result")) { - response.add("result", txid.get("result")); - response.add("error", JsonNull.INSTANCE); - - break; - } - - response.add("result", txid); - response.add("error", JsonNull.INSTANCE); - break; - } - case "getrawtransaction": { - if (params.size() > 2) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: getrawtransaction\n\ntxid (string, required) - TXID is required, verbose(optional)"); - - response.add("error", errorJSON); - break; - } - - String txid = params.get(0).getAsString(); - boolean verbose = false; - - if (params.size() == 2) { - String v = params.get(1).toString(); - if (v.equalsIgnoreCase("true") || v.equals("1")) - verbose = true; - } - - JsonObject rawTransaction = httpClient.getRawTransaction(coin.getTicker(), txid, verbose); - - if (rawTransaction == null || rawTransaction.has("error") && !rawTransaction.get("error").isJsonNull()) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -5); - errorJSON.addProperty("message", "No information available about transaction"); - - response.add("error", errorJSON); - - break; - } - - if (rawTransaction.has("result")) { - response.add("result", rawTransaction.get("result")); - response.add("error", JsonNull.INSTANCE); - - break; - } - - response.add("result", rawTransaction); - response.add("error", JsonNull.INSTANCE); - - break; - } - case "getrawmempool": { - if (params.size() > 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: getrawmempool\n\nverbose (int, optional) - VERBOSE is optional"); - - response.add("error", errorJSON); - break; - } - - boolean verbose = false; - - if (params.size() == 2) { - String v = params.get(1).toString(); - if (v.equalsIgnoreCase("true") || v.equals("1")) - verbose = true; - } - - JsonObject rawMempool = httpClient.getRawMempool(coin.getTicker(), verbose); - - if (rawMempool == null || rawMempool.has("error") && !rawMempool.get("error").isJsonNull()) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "No information available"); - - response.add("error", errorJSON); - - break; - } - - if (rawMempool.has("result")) { - response.add("result", rawMempool.get("result")); - response.add("error", JsonNull.INSTANCE); - - break; - } - - response.add("result", rawMempool); - response.add("error", JsonNull.INSTANCE); - - break; - } - case "getblock": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: getblock hash\n\nhash (string, required) - Hash of block to retrieve"); - - response.add("error", errorJSON); - break; - } - - String hash; - try { - hash = params.get(0).getAsString(); - hash = hash.replaceAll("\"", ""); - } catch (JsonParseException e) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Error parsing JSON!"); - response.add("error", errorJSON); - - e.printStackTrace(); - break; - } - - JsonObject block = httpClient.getBlock(coin.getTicker(), hash, true); - - if (block == null || block.has("error") && !block.get("error").isJsonNull()) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Error while retrieving block"); - response.add("error", errorJSON); - - break; - } - - if (block.has("result")) { - response.add("result", block.get("result")); - response.add("error", JsonNull.INSTANCE); - - break; - } - - response.add("result", block); - response.add("error", JsonNull.INSTANCE); - break; - } - case "gettransaction": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: gettransaction txid\n\ntxid (string, required) - The transaction ID"); - - response.add("error", errorJSON); - break; - } - - String txid = params.get(0).getAsString(); - - JsonObject transaction = httpClient.getTransaction(coin.getTicker(), txid, true); - - if (transaction == null || transaction.has("error") && !transaction.get("error").isJsonNull()) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Error while obtaining transaction!"); - response.add("error", errorJSON); - - break; - } - - if (transaction.has("result")) { - response.add("result", transaction.get("result")); - response.add("error", JsonNull.INSTANCE); - - break; - } - - response.add("result", transaction); - response.add("error", JsonNull.INSTANCE); - break; - } - case "getaddressesbyaccount": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: getaddressesbyaccount account\n\naccount (string, required) - The account from which to grab addresses. The only account available (for now) is 'main'."); - - response.add("error", errorJSON); - break; - } - - String account = params.get(0).getAsString(); - - if (account.equals("main")) { - JsonArray addresses = new JsonArray(); - for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { - addresses.add(addressBalance.getAddress().toBase58()); - } - - response.add("result", addresses); - response.add("error", JsonNull.INSTANCE); - } else { - response.add("result", new JsonArray()); - response.add("error", JsonNull.INSTANCE); - } - break; - } - case "createrawtransaction": { - if (params.size() < 2 || params.size() > 3) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", - "Usage: createrawtransaction inputs outputs\n\ninputs (string, required) - Inputs in JSON format\nexample: [{\"txid\": \"id\", \"vout\": n}, ...]\n\noutputs (string, required) - Outputs in JSON format\nexample (legacy): {\"address1\": amount1, \"address2\": amount2, ...}\nexample (new): [{\"address\": \"address1\", \"amount\": amount1}, {\"address\": \"address1\", \"amount\": amount2}, ...]"); - response.add("error", errorJSON); - break; - } - - JsonArray inputs; - // Instead of using a JsonObject for outputs, we’ll parse outputs into a list of - // OutputEntry. - List outputEntries = new ArrayList<>(); - long locktime = 0; - - if (params.size() >= 3) - locktime = params.get(3).getAsLong(); - - try { - inputs = params.get(0).getAsJsonArray(); - for (int i = 0; i < inputs.size(); i++) { - JsonObject input = inputs.get(i).getAsJsonObject(); - if (!input.has("txid") || !input.has("vout")) - throw new JsonParseException("Bad transaction input."); - } - - JsonElement outputsElem = params.get(1); - if (outputsElem.isJsonArray()) { - // New array format: allows duplicate outputs with same address. - JsonArray outputsArray = outputsElem.getAsJsonArray(); - for (JsonElement elem : outputsArray) { - JsonObject obj = elem.getAsJsonObject(); - if (!obj.has("address") || !obj.has("amount")) - throw new JsonParseException("Output entry must have 'address' and 'amount'"); - String addr = obj.get("address").getAsString(); - double amt = obj.get("amount").getAsDouble(); - outputEntries.add(new OutputEntry(addr, amt)); - } - } else if (outputsElem.isJsonObject()) { - // Legacy format: keys are addresses. - JsonObject outputsObject = outputsElem.getAsJsonObject(); - for (String addr : outputsObject.keySet()) { - double amt = outputsObject.get(addr).getAsDouble(); - outputEntries.add(new OutputEntry(addr, amt)); - } - } else { - throw new JsonParseException("Invalid outputs format"); - } - } catch (JsonParseException e) { - LOGGER.log(Level.FINER, - "[http-server-handler] ERROR: Error while parsing JSON for createrawtransaction!"); - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -2); - errorJSON.addProperty("message", "Error parsing JSON"); - response.add("error", errorJSON); - break; - } - - Transaction tx = new Transaction(coin.getNetworkParameters()); - if (locktime > 0 && !tx.isTimeLocked()) { - tx.setLockTime(locktime); - } - - boolean inputSuccess = true; - for (int i = 0; i < inputs.size(); i++) { - JsonObject input = inputs.get(i).getAsJsonObject(); - try { - String txid = input.get("txid").getAsString(); - int vout = input.get("vout").getAsInt(); - tx.addInput(Sha256Hash.wrap(txid), vout, ScriptBuilder.createInputScript(null)); - } catch (Exception e) { - LOGGER.log(Level.FINER, - "[http-server-handler] ERROR: Error while constructing transaction (input phase)!"); - txConstructionError(response, e, "Error while constructing transaction (input phase)"); - inputSuccess = false; - break; - } - } - if (!inputSuccess) - break; - - boolean outputSuccess = true; - - // First, add P2SH outputs. - for (OutputEntry entry : outputEntries) { - try { - Address address = Address.fromBase58(coin.getNetworkParameters(), entry.address); - Coin outputValue = Coin.valueOf((long) Math.floor(entry.amount * Coin.COIN.value)); - if (isP2SHAddress(entry.address)) { - LOGGER.log(Level.FINER, "[http-server-handler] P2SH Address Found: " + entry.address); - Script p2shScript = ScriptBuilder.createP2SHOutputScript(address.getHash160()); - tx.addOutput(outputValue, p2shScript); - } - } catch (Exception e) { - LOGGER.log(Level.FINER, - "[http-server-handler] ERROR: Error while constructing transaction (output phase)!"); - e.printStackTrace(); - txConstructionError(response, e, "Error while constructing transaction (output phase)"); - outputSuccess = false; - } - } - // Then, add non-P2SH outputs. - for (OutputEntry entry : outputEntries) { - try { - Address address = Address.fromBase58(coin.getNetworkParameters(), entry.address); - Coin outputValue = Coin.valueOf((long) Math.floor(entry.amount * Coin.COIN.value)); - if (!isP2SHAddress(entry.address)) { - tx.addOutput(outputValue, address); - } - } catch (Exception e) { - LOGGER.log(Level.FINER, - "[http-server-handler] ERROR: Error while constructing transaction (output phase)!"); - e.printStackTrace(); - txConstructionError(response, e, "Error while constructing transaction (output phase)"); - outputSuccess = false; - } - } - - if (!outputSuccess) - break; - - String hexTx = new String(Hex.encode(tx.bitcoinSerialize())); - LOGGER.log(Level.FINER, "[http-server-handler] DEBUG: Raw transaction = " + hexTx); - response.addProperty("result", hexTx); - response.add("error", JsonNull.INSTANCE); - break; - } - case "decoderawtransaction": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: decoderawtransaction rawtx\n\nrawtx (string, required) - The raw transaction to decode, hex encoded."); - - response.add("error", errorJSON); - break; - } - - String rawTx = params.get(0).getAsString(); - Transaction tx; - - try { - tx = new Transaction(coin.getNetworkParameters(), Hex.decode(rawTx)); - } catch (Exception e) { - e.printStackTrace(); - getInvalidTxResponse(response, e); - break; - } - - JsonObject txJSON = new JsonObject(); - - txJSON.addProperty("txid", tx.getHashAsString()); - txJSON.addProperty("version", tx.getVersion()); - txJSON.addProperty("locktime", tx.getLockTime()); - JsonArray vin = new JsonArray(); - - boolean inputParseSuccess = true; - - for (TransactionInput input : tx.getInputs()) { - try { - JsonObject thisVin = new JsonObject(); - thisVin.addProperty("txid", input.getOutpoint().getHash().toString()); - thisVin.addProperty("vout", input.getOutpoint().getIndex()); - - JsonObject scriptSig = new JsonObject(); - scriptSig.addProperty("asm", canonicalizeASM(input.getScriptSig().toString())); - scriptSig.addProperty("hex", new String(Hex.encode(input.getScriptSig().getProgram()))); - - thisVin.add("scriptSig", scriptSig); - thisVin.addProperty("sequence", input.getSequenceNumber()); - - vin.add(thisVin); - } catch (Exception e) { - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: Error while parsing transaction inputs!"); - e.printStackTrace(); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1008); - errorJSON.addProperty("message", "Error while parsing transaction inputs"); - - response.add("error", errorJSON); - - inputParseSuccess = false; - } - } - - if (!inputParseSuccess) - break; - - txJSON.add("vin", vin); - JsonArray vout = new JsonArray(); - - for (TransactionOutput output : tx.getOutputs()) { - try { - Script.ScriptType type = output.getScriptPubKey().getScriptType(); - - JsonObject thisVout = new JsonObject(); - - thisVout.addProperty("value", (double) output.getValue().value / 100000000.0); - thisVout.addProperty("n", output.getIndex()); - - JsonObject scriptPubKey = new JsonObject(); - scriptPubKey.addProperty("asm", canonicalizeASM(output.getScriptPubKey().toString())); - scriptPubKey.addProperty("hex", new String(Hex.encode(output.getScriptPubKey().getProgram()))); - - if (type == Script.ScriptType.P2SH) { - scriptPubKey.addProperty("reqSigs", 1); - } else { - scriptPubKey.addProperty("reqSigs", output.getScriptPubKey().getNumberOfSignaturesRequiredToSpend()); - } - - getScriptType(scriptPubKey, type); - - JsonArray addresses = new JsonArray(); - addresses.add(output.getScriptPubKey().getToAddress(coin.getNetworkParameters()).toBase58()); - - scriptPubKey.add("addresses", addresses); - thisVout.add("scriptPubKey", scriptPubKey); - - vout.add(thisVout); - } catch (Exception e) { - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: Error while parsing transaction outputs!"); - e.printStackTrace(); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1009); - errorJSON.addProperty("message", "Error while parsing transaction outputs"); - - response.add("error", errorJSON); - } - } - - txJSON.add("vout", vout); - - response.add("result", txJSON); - response.add("error", JsonNull.INSTANCE); - - break; - } - case "signrawtransaction": { - if (params.size() < 1 || params.size() > 3) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: signrawtransaction rawtx\n\nrawtx (string, required) - The raw transaction to sign, hex encoded."); - - response.add("error", errorJSON); - break; - } - - String rawTx = params.get(0).getAsString(); - Transaction tx; -// JsonArray prevtxs; -// JsonArray privkeys; -// -// if (params.size() == 3 && !(params.get(0).isJsonNull() || params.get(2).isJsonNull())) { -// prevtxs = params.get(1).getAsJsonArray(); -// privkeys = params.get(2).getAsJsonArray(); -// } - - try { - tx = new Transaction(coin.getNetworkParameters(), Hex.decode(rawTx)); - } catch (Exception e) { - e.printStackTrace(); - getInvalidTxResponse(response, e); - break; - } - - Transaction signedTx = new Transaction(coin.getNetworkParameters()); - - boolean complete = true; - - for (TransactionOutput output : tx.getOutputs()) { - signedTx.addOutput(output); - } - - for (TransactionInput input : tx.getInputs()) { - Sha256Hash txid = input.getOutpoint().getHash(); - long vout = input.getOutpoint().getIndex(); - - ECKey signingKey = getSigningKey(txid, vout); - UTXO utxo = getUtxo(txid, vout); - - if (utxo == null || signingKey == null) { - getInvalidTxResponse(response, new Exception("Transaction contains an utxo/input which does not exist in our wallet.")); - break; - } - - org.bitcoinj.core.UTXO bUtxo = utxo.createUTXO(); - - TransactionOutPoint outPoint = new TransactionOutPoint(coin.getNetworkParameters(), bUtxo.getIndex(), bUtxo.getHash()); - - signedTx.addSignedInput(outPoint, bUtxo.getScript(), signingKey, Transaction.SigHash.ALL, true); -// utxo.setSpent(true); - } - - String signedTxHex = new String(Hex.encode(signedTx.bitcoinSerialize())); - JsonObject resultJSON = new JsonObject(); - resultJSON.addProperty("hex", signedTxHex); - resultJSON.addProperty("complete", complete); - - response.add("result", resultJSON); - response.add("error", JsonNull.INSTANCE); - - LOGGER.log(Level.FINER, "[DEBUG http-server-handler] Signed Raw Transaction: " + response.toString()); - break; - } - case "gettxout": { - if (params.size() < 2 || params.size() > 3) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: gettxout txid n [include_mempool]\\n" + // - "\\n" + // - "txid (string, required) - The transaction ID\\n" + // - "n (numeric, required) - The vout value\\n" + // - "include_mempool (boolean, optional, default=true) - Whether to include the mempool (WARNING: This can block execution for several seconds)"); - - response.add("error", errorJSON); - break; - } - - String txid = params.get(0).getAsString(); - int n = params.get(1).getAsInt(); - boolean includeMempool = true; - if (params.size() == 3) { - includeMempool = params.get(2).getAsBoolean(); - } - - // attempt to find UTXO in cache - UTXO requested = this.getUtxo(Sha256Hash.wrap(txid), n); - - if (requested != null) { - LOGGER.log(Level.FINER, "[http-server-handler] Using cached UTXO for gettxout"); - - org.bitcoinj.core.UTXO utxo = requested.createUTXO(); - JsonObject resultJSON = new JsonObject(); - - resultJSON.addProperty("confirmations", - (CoinInstance.getBlockCountByTicker(coin.getTicker()) - utxo.getHeight()) + 1); - resultJSON.addProperty("value", utxo.getValue().value / 100000000.0); - - JsonObject scriptPubKey = new JsonObject(); - scriptPubKey.addProperty("asm", utxo.getScript().toString()); - scriptPubKey.addProperty("hex", new String(Hex.encode(utxo.getScript().getProgram()))); - scriptPubKey.addProperty("reqSigs", utxo.getScript().getNumberOfSignaturesRequiredToSpend()); - - Script.ScriptType type = utxo.getScript().getScriptType(); - getScriptType(scriptPubKey, type); - - JsonArray addresses = new JsonArray(); - addresses.add(utxo.getAddress()); - scriptPubKey.add("addresses", addresses); - - resultJSON.add("scriptPubKey", scriptPubKey); - resultJSON.addProperty("coinbase", utxo.isCoinbase()); - - response.add("result", resultJSON); - response.add("error", JsonNull.INSTANCE); - break; - } - - if (!includeMempool) { - LOGGER.log(Level.FINER, "[http-server-handler] WARNING: Client requested UTXO that is not ours!"); - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - - errorJSON.addProperty("code", -5); - errorJSON.addProperty("message", "Invalid or non-wallet transaction ID (not ours)"); - response.add("error", errorJSON); - break; - } - - // considering mempool, find/wait for the transaction - JsonObject transaction = null; - int retries = includeMempool ? 5 : 1; - for (int i = 0; i < retries; i++) { - transaction = httpClient.getTransaction(coin.getTicker(), txid, true); - if (transaction == null || transaction.has("result") && transaction.get("result").isJsonNull()) { - if (i < retries - 1) { - try { - Thread.sleep(2000); - } catch (Exception e) { - } - continue; - } - } else { - break; - } - } - - if (transaction == null || transaction.has("error") && !transaction.get("error").isJsonNull()) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - - errorJSON.addProperty("code", -5); - errorJSON.addProperty("message", "Invalid transaction ID"); - response.add("error", errorJSON); - break; - } - - // extract the UTXO - try { - // Note: will throw when attempting to parse a coinbase utxo (that's fine) - JsonObject result = transaction.getAsJsonObject("result"); - JsonElement confirmations = result.get("confirmations"); - JsonArray vout = result.getAsJsonArray("vout"); - - if (vout.size() <= n) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - - errorJSON.addProperty("code", -5); - errorJSON.addProperty("message", "Invalid transaction output index"); - response.add("error", errorJSON); - break; - } - - JsonObject entry = vout.get(n).getAsJsonObject(); - JsonElement value = entry.get("value"); - JsonObject scriptPubKey = entry.getAsJsonObject("scriptPubKey"); - JsonElement addr = scriptPubKey.get("address"); - JsonArray addresses = scriptPubKey.getAsJsonArray("addresses"); - - String address = null; - if(addr != null) { - address = addr.getAsString(); - } else { - address = addresses.asList().get(0).getAsString(); - } - - // ensure address belongs to our wallet - boolean isOurs = false; - for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { - String addressCheck = addressBalance.getAddress().toString(); - if (address.equals(addressCheck)) { - isOurs = true; - break; - } - } - - if (!isOurs) { - LOGGER.log(Level.FINER, "[http-server-handler] WARNING: Client requested UTXO that cannot be ours!"); - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - - errorJSON.addProperty("code", -5); - errorJSON.addProperty("message", "Invalid or non-wallet transaction ID (cannot be ours)"); - response.add("error", errorJSON); - break; - } - - int count = 0; - if (confirmations != null) { - count = confirmations.getAsInt(); - } - - // ensure UTXO is unspent - // Caution: Beware of race conditions; the backend might return results for 'getTransaction' - // before updating entries for 'getUtxos'. As a result, we only check confirmed transactions. - // Note that unconfirmed UTXOs spent in the memory pool will still be returned, leading to - // a slightly different behavior compared to a core wallet. - if (count > 0) { - // Note: this request is expensive - boolean unspent = false; - JsonArray utxos = httpClient.getUtxosUncached(coin.getTicker(), new String[] { address }); - for (JsonElement utxo : utxos.asList()) { - String newtxid = utxo.getAsJsonObject().get("txid").getAsString(); - int newvout = utxo.getAsJsonObject().get("vout").getAsInt(); - if (newtxid.equals(txid) && newvout == n) { - unspent = true; - break; - } - } - - if (!unspent) { - LOGGER.log(Level.FINER, "[http-server-handler] WARNING: Client requested UTXO that was already spent!"); - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - - errorJSON.addProperty("code", -5); - errorJSON.addProperty("message", "Invalid or non-wallet transaction ID (already spent)"); - response.add("error", errorJSON); - break; - } - } - - // assemble result - JsonObject resultJSON = new JsonObject(); - resultJSON.addProperty("confirmations", count); - resultJSON.add("value", value); - resultJSON.add("scriptPubKey", scriptPubKey); - resultJSON.addProperty("coinbase", false); - - response.add("result", resultJSON); - response.add("error", JsonNull.INSTANCE); - } catch (Exception e) { - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: Error while parsing transaction!"); - e.printStackTrace(); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1010); - errorJSON.addProperty("message", "Error while parsing transaction"); - - response.add("error", errorJSON); - } - - break; - } - case "getnewaddress": { - if (params.size() != 0) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: getnewaddress"); - - response.add("error", errorJSON); - break; - } - - AddressBalance newAddress = coin.generateAddress(true); - response.addProperty("result", newAddress.getAddress().toBase58()); - response.add("error", JsonNull.INSTANCE); - break; - } - case "importprivkey": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: importprivkey privatekey\n\nNOTE: Key's are not persistent!\n\nprivatekey (string, required)"); - - response.add("error", errorJSON); - break; - } - - coin.importPrivateKey(params.get(0).getAsString()); - response.addProperty("result", ""); - response.add("error", JsonNull.INSTANCE); - break; - } - case "dumpprivkey": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: dumpprivkey address\n\naddress (string, required)"); - - response.add("error", errorJSON); - break; - } - - AddressBalance addressBalance = coin.getAddress(params.get(0).getAsString()); - - if (addressBalance == null) { - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -4); - errorJSON.addProperty("message", "Address does not exist"); - - response.add("result", JsonNull.INSTANCE); - response.add("error", errorJSON); - - break; - } - - response.addProperty("result", addressBalance.getPrivateKey().toBase58()); - response.add("error", JsonNull.INSTANCE); - break; - } - case "signmessage": { - if (params.size() != 2) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: signmessage address message\n\naddress (string, required) - The address whose private key to use to sign the message\nmessage (string, required) - The message to sign"); - - response.add("error", errorJSON); - break; - } - - String addr = params.get(0).getAsString(); - String message = params.get(1).getAsString(); - - AddressBalance address = coin.getAddressBalance(addr); - if (address == null) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -5); - errorJSON.addProperty("message", "Invalid or non-wallet address"); - - response.add("error", errorJSON); - break; - } - - ECKey key = address.getPrivateKey().getKey(); - String signatureB64 = signMessage(key, message); - - response.addProperty("result", signatureB64); - response.add("error", JsonNull.INSTANCE); - break; - } - case "verifymessage": { - if (params.size() != 3) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: verifymessage address signature message\n\naddress (string, required) - The address whose private key to use to sign the message\nsignature (string, required) - The signature to verify, base64 encoded\nmessage (string, required) - The message to sign"); - - response.add("error", errorJSON); - break; - } - - String addr = params.get(0).getAsString(); - String signatureB64 = params.get(1).getAsString(); - String message = params.get(2).getAsString(); - - boolean verified = false; - - try { - ECKey key = signedMessageToKey(signatureB64, message); - verified = verifyMessage(key, signatureB64, message); - if (!verified) - throw new SignatureException("Signature was not verified."); - - String derivedAddr = key.toAddress(coin.getNetworkParameters()).toBase58(); - if (!addr.equals(derivedAddr)) { - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: Addresses do not match! Failing."); - verified = false; - } - } catch (Exception e) { - LOGGER.log(Level.FINER, "[http-server-handler] Error while verifying signature! Invalid signature?"); - e.printStackTrace(); - - response.addProperty("result", verified); - response.add("error", JsonNull.INSTANCE); - break; - } - - response.addProperty("result", verified); - response.add("error", JsonNull.INSTANCE); - break; - } - case "sendtransaction": { - if (params.size() != 2) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: sendtransaction address amount\n\naddress (string, required)\namount (number, required)"); - - response.add("error", errorJSON); - break; - } - - String address; - double amount; - try { - address = params.get(0).getAsString(); - amount = params.get(1).getAsDouble(); - } catch (JsonParseException e) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Error parsing JSON!"); - response.add("error", errorJSON); - - e.printStackTrace(); - break; - } - - Transaction transaction; - try { - transaction = WalletHelper.createTransactionSimple(coin.getTicker(), address, amount); - WalletHelper.setAsSpent(coin.getTicker(), transaction, true); - } catch (JsonParseException e) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Error while creating transaction!"); - response.add("error", errorJSON); - - e.printStackTrace(); - break; - } - - JsonObject txid = httpClient.sendRawTransaction(coin.getTicker(), new String(Hex.encode(transaction.bitcoinSerialize()))); - if (txid == null || txid.has("error") && !txid.get("error").isJsonNull()) { - int code = -1; - - if (txid != null) - code = txid.get("error").getAsInt(); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", code); - errorJSON.addProperty("message", "Error sending transaction!"); - response.add("error", errorJSON); - - break; - } - - if (txid.has("result")) { - response.add("result", txid.get("result")); - response.add("error", JsonNull.INSTANCE); - - break; - } - - response.add("result", txid); - response.add("error", JsonNull.INSTANCE); - break; - } - case "version": { - response.addProperty("result", Version.CLIENT_VERSION); - response.add("error", JsonNull.INSTANCE); - break; - } - case "validateaddress": { - if (params.size() != 1) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1); - errorJSON.addProperty("message", "Usage: validateaddress address\n\naddress (string, required) - The address to validate."); - - response.add("error", errorJSON); - break; - } - - JsonObject resultJSON = new JsonObject(); - String address = params.get(0).getAsString(); - - boolean isValidAddress = Utility.isValidAddress(coin.getNetworkParameters(), address); - - resultJSON.addProperty("isvalid", isValidAddress); - resultJSON.addProperty("address", address); - - boolean isP2SH = false; - String scriptPubKey = ""; - if (isValidAddress) { - Address toAddress = Address.fromBase58(coin.getNetworkParameters(), address); - if (isP2SHAddress(address)) { - isP2SH = true; - - TransactionOutput output = new TransactionOutput(coin.getNetworkParameters(), null, Coin.valueOf(0), toAddress); - scriptPubKey = new String(Hex.encode(output.getScriptPubKey().getProgram())); - } - } - - resultJSON.addProperty("scriptPubKey", scriptPubKey); - resultJSON.addProperty("isscript", isP2SH); - - response.add("result", resultJSON); - response.add("error", JsonNull.INSTANCE); - break; - } - case "help": { - String helpString = "JSON-RPC server for " + CoinTickerUtils.tickerToString(coin.getTicker()) + "\n" - + "This JSON-RPC server is served by " + CoinInstance.getVersionString() + "\n" - + "\n" - + "help - This command help.\n" - + "version - Get version\n" - + "\n=====Blockchain=====\n" - + "gettxout - Get info about an unspent transaction output\n" - + "\n=====Network=====\n" - + "getinfo - Get information such as balances, protocol version, and more.\n" - + "getblockcount - Get block count\n" - + "getnetworkinfo - Get network information\n" - + "getrawmempool - Get raw mempool\n" - + "getblockchaininfo - Get blockchain info\n" - + "getblockhash - Get the hash of a block at a given height\n" - + "getblock - Get a block's JSON representation given its hash\n" - + "\n=====Wallet=====\n" - + "listunspent - Get all UTXOs in the wallet\n" - + "listtransactions - Get all transactions in the wallet\n" - + "getnewaddress - Generate a new address\n" - + "gettransaction - Get a transaction given its TXID\n" - + "getaddressesbyaccount - Get addresses belonging to a given account. The only account available is 'main' which contains all addresses.\n" - + "importprivkey - Import an address given it's privkey\n" - + "dumpprivkey
- Dump an addresses private key\n" - + "\n=====Utilities=====\n" - + "signmessage
- Sign a message with a given address' private key\n" - + "verifymessage
- Verify a signature for a message signed by a given address\n" - + "validateaddress
- Validate a given address\n" - + "sendtransaction
- Create and broadcast a signed transaction to the network\n" - + "\n=====Raw Transactions=====\n" - + "createrawtransaction - Create a raw transaction given inputs and outputs in JSON format. For more info, run createrawtransaction with no arguments.\n" - + "decoderawtransaction - Get a raw transaction's JSON representation\n" - + "signrawtransaction - Sign a raw transaction\n" - + "sendrawtransaction - Broadcast a signed raw transaction to the network\n"; - - response.addProperty("result", helpString); - response.add("error", JsonNull.INSTANCE); - break; - } - default: { - JsonObject methodNotFound = new JsonObject(); - methodNotFound.addProperty("code", -32601); - methodNotFound.addProperty("message", "Method not found."); - response.add("error", methodNotFound); - response.add("result", JsonNull.INSTANCE); - break; - } - } - - return response; - } - - private String canonicalizeASM(String asm) { - return asm - .replaceAll("DUP", "OP_DUP") - .replaceAll("HASH160", "OP_HASH160") - .replaceAll("EQUALVERIFY", "OP_EQUALVERIFY") - .replaceAll("CHECKSIG", "OP_CHECKSIG") - .replaceAll("RETURN", "OP_RETURN") - .replaceAll("PUSHDATA", "") - .replaceAll("\\[", "") - .replaceAll("]", "") - .replaceAll("\\([0-9]+\\)", ""); - } - - private byte[] formatMessageForSigning(String message) { - String header = null; - - switch (coin.getTicker()) { - case BLOCKNET: - case BLOCKNET_TESTNET5: - header = "Blocknet Signed Message:\n"; - break; - case BITCOIN: - // case BITCOIN_CASH: - // header = "Bitcoin Signed Message:\n"; - // break; - case LITECOIN: - header = "Litecoin Signed Message:\n"; - break; - // case ALQOCOIN: - // case PHORECOIN: - case PIVX: - header = "DarkNet Signed Message:\n"; - break; - case DASHCOIN: - header = "DarkCoin Signed Message:\n"; - break; - case UNOBTANIUM: - header = "Unobtanium Signed Message:\n"; - break; - // case DIGIBYTE: - // header = "DigiByte Signed Message:\n"; - // break; - // case BITBAY: - // header = "BitBay Signed Message:\n"; - // break; - // case POLISCOIN: - // header = "Polis Signed Message:\n"; - // break; - // case RAVENCOIN: - // header = "Raven Signed Message:\n"; - // break; - case DOGECOIN: - header = "Dogecoin Signed Message:\n"; - break; - // case TREZARCOIN: - // header = "Trezarcoin Signed Message:\n"; - // break; - case SYSCOIN: - header = "Syscoin Signed Message:\n"; - break; - default: - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: Unsupported coin. This should never happen."); - break; - } - - try { - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - byte[] headerBytes = header.getBytes(StandardCharsets.UTF_8); - bos.write(headerBytes.length); - bos.write(headerBytes); - byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); - VarInt size = new VarInt(messageBytes.length); - bos.write(size.encode()); - bos.write(messageBytes); - return bos.toByteArray(); - } catch (IOException e) { - LOGGER.log(Level.FINER, "[http-server-handler] Error while formatting message for signing!"); - e.printStackTrace(); - } - - return null; - } - - private String signMessage(ECKey key, String message) { - byte[] formatted = formatMessageForSigning(message); - Preconditions.checkNotNull(formatted, "Formatted message is null"); - Sha256Hash hash = Sha256Hash.twiceOf(formatted); - ECKey.ECDSASignature signature = key.sign(hash); - byte recoveryId = -1; - for (int i = 0; i < 4; i++) { - ECKey k = ECKey.recoverFromSignature(i, signature, hash, key.isCompressed()); - if (k != null && Arrays.equals(k.getPubKey(), key.getPubKey())) { - recoveryId = (byte) i; - break; - } - } - if (recoveryId == -1) - throw new IllegalStateException("Recovery ID is invalid."); - - byte[] sigData = new byte[65]; - byte headerByte = (byte) (recoveryId + 27 + (key.isCompressed() ? 4 : 0)); - sigData[0] = headerByte; - System.arraycopy(Utils.bigIntegerToBytes(signature.r, 32), 0, sigData, 1, 32); - System.arraycopy(Utils.bigIntegerToBytes(signature.s, 32), 0, sigData, 33, 32); - - return new String(Base64.encode(sigData)); - } - - private ECKey signedMessageToKey(String signatureB64, String message) throws SignatureException { - byte[] signatureEncoded; - try { - signatureEncoded = Base64.decode(signatureB64); - } catch (RuntimeException e) { - throw new SignatureException("Could not decode base64", e); - } - - if (signatureEncoded.length < 65) - throw new SignatureException("Signature truncated, expected 65 bytes and got " + signatureEncoded.length); - int header = signatureEncoded[0] & 0xFF; - - if (header < 27 || header > 34) - throw new SignatureException("Header byte out of range: " + header); - - BigInteger r = new BigInteger(1, Arrays.copyOfRange(signatureEncoded, 1, 33)); - BigInteger s = new BigInteger(1, Arrays.copyOfRange(signatureEncoded, 33, 65)); - ECKey.ECDSASignature sig = new ECKey.ECDSASignature(r, s); - byte[] messageBytes = formatMessageForSigning(message); - Preconditions.checkNotNull(messageBytes, "Message bytes are null."); - - Sha256Hash messageHash = Sha256Hash.twiceOf(messageBytes); - boolean compressed = false; - if (header >= 31) { - compressed = true; - header -= 4; - } - int recId = header - 27; - ECKey key = ECKey.recoverFromSignature(recId, sig, messageHash, compressed); - if (key == null) - throw new SignatureException("Could not recover public key from signature"); - return key; - } - - private boolean verifyMessage(ECKey key, String signatureB64, String message) { - boolean verified = false; - try { - ECKey k = signedMessageToKey(signatureB64, message); - if (Arrays.equals(k.getPubKey(), key.getPubKey())) - verified = true; - } catch (SignatureException e) { - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: Error while verifying message. Invalid signature?"); - e.printStackTrace(); - } - - return verified; - } - - private boolean isP2SHAddress(String address) { - byte[] versionAndDataBytes = Base58.decodeChecked(address); - int version = versionAndDataBytes[0] & 0xFF; - - if (coin.getNetworkParameters().getAcceptableAddressCodes().length > 2) { - LOGGER.log(Level.FINER, "[http-server-handler] Coin has more than 2 acceptable address codes"); - - for (int t : coin.getNetworkParameters().getAcceptableAddressCodes()) { - if (coin.getNetworkParameters().getAddressHeader() != t && t == version) { - return true; - } - } - } - - return coin.getNetworkParameters().getP2SHHeader() == version; - } - - private void getScriptType(JsonObject scriptPubKey, Script.ScriptType type) { - String typeStr = "unknown"; - - switch (type) { - case P2PKH: - typeStr = "pubkeyhash"; - break; - case P2SH: - typeStr = "scripthash"; - break; - default: - break; - } - - scriptPubKey.addProperty("type", typeStr); - } - - private UTXO getUtxo(Sha256Hash txid, long vout) { - for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { - for (UTXO utxo : addressBalance.getUtxos()) { - if (utxo.createUTXO().getHash().equals(txid) && utxo.getVout() == vout) { - return utxo; - } else { - LOGGER.log(Level.FINER, "[http-server-handler] DEBUG: UTXO " + utxo.createUTXO().getHash().toString() + " does not equal " + txid.toString()); - } - } - } - - return null; - } - - private ECKey getSigningKey(Sha256Hash txid, long vout) { - for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { - for (UTXO utxo : addressBalance.getUtxos()) { - if (utxo.createUTXO().getHash().equals(txid) && utxo.getVout() == vout) { - return addressBalance.getPrivateKey().getKey(); - } else { - LOGGER.log(Level.FINER, "[http-server-handler] DEBUG: UTXO " + utxo.createUTXO().getHash().toString() + " does not equal " + txid.toString()); - } - } - } - - return null; - } - - private void getInvalidTxResponse(JsonObject response, Exception e) { - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: Error while decoding raw tx!"); - e.printStackTrace(); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1007); - errorJSON.addProperty("message", "Error decoding raw tx. Invalid transaction?"); - - response.add("error", errorJSON); - } - - private void txConstructionError(JsonObject response, Exception e, String s) { - e.printStackTrace(); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1006); - errorJSON.addProperty("message", s); - response.add("error", errorJSON); - } - - private boolean isXRouterConfigInvalid(JsonObject response, BlocknetPeer blocknetPeer) { - if (blocknetPeer.getxRouterConfiguration() == null) { - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1005); - errorJSON.addProperty("message", "Server has not received XRouter configuration from node."); - response.add("error", errorJSON); - - return true; - } - return false; - } - - private void getXRouterResponse(JsonObject response, CountDownLatch latch, AtomicReference xRouterResult, BlocknetPeer blocknetPeer) { - int timeoutPeriod = blocknetPeer.getxRouterConfiguration().getTimeout(); - - try { - if (timeoutPeriod == 0) { - latch.await(10, TimeUnit.SECONDS); - } else { - latch.await(timeoutPeriod, TimeUnit.SECONDS); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - - if (xRouterResult.get() == null || xRouterResult.get().isEmpty()) { - LOGGER.log(Level.FINER, "[http-server-handler] ERROR: XRouter request timed out or errored! Timeout period = " + timeoutPeriod); - - response.add("result", JsonNull.INSTANCE); - JsonObject errorJSON = new JsonObject(); - errorJSON.addProperty("code", -1004); - errorJSON.addProperty("message", "XRouter request timed out"); - - response.add("error", errorJSON); - } else { - JsonObject xRouterResObj = null; - if (isValidJSON(xRouterResult.get())) { - xRouterResObj = new Gson().fromJson(xRouterResult.get(), JsonObject.class); - } - - if (xRouterResObj != null && xRouterResObj.has("result")) { - if (xRouterResObj.has("code")) - response.add("code", xRouterResObj.get("code")); - if (xRouterResObj.has("error")) - response.add("error", xRouterResObj.get("error")); - response.add("result", xRouterResObj.get("result")); - } else { - if (xRouterResObj != null) { - response.add("result", xRouterResObj); - } else { - response.addProperty("result", xRouterResult.get()); - } - - response.add("error", JsonNull.INSTANCE); - } - } - } - - private boolean isValidJSON(String content) { - try { - new Gson().fromJson(content, JsonObject.class); - } catch (JsonParseException jsExcp) { - try { - new Gson().fromJson(content, JsonArray.class); - } catch (JsonParseException jsExcp1) { - return false; - } - } - return true; - } - - private boolean writeResponse(ChannelHandlerContext ctx, FullHttpResponse httpResponse, FullHttpRequest request) { - boolean keepAlive = false; - if (request != null) - keepAlive = HttpUtil.isKeepAlive(request); - - httpResponse.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json"); - httpResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, httpResponse.content().readableBytes()); - httpResponse.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - httpResponse.headers().set(HttpHeaderNames.SERVER, CoinInstance.getVersionString()); - - LOGGER.log(Level.FINER, "[http-server-handler] Writing response to channel. Keep alive? " + keepAlive); - LOGGER.log(Level.FINER, "[http-server-handler] Response content: " + httpResponse.content().toString(CharsetUtil.UTF_8)); - ctx.write(httpResponse); - - return keepAlive; - } - - private static void send100Continue(ChannelHandlerContext ctx) { - FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE); - ctx.write(response); - } -} diff --git a/src/main/java/io/cloudchains/app/net/api/http/server/HTTPServerInitializer.java b/src/main/java/io/cloudchains/app/net/api/http/server/HTTPServerInitializer.java deleted file mode 100644 index c485f2c..0000000 --- a/src/main/java/io/cloudchains/app/net/api/http/server/HTTPServerInitializer.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.cloudchains.app.net.api.http.server; - -import io.cloudchains.app.net.CoinInstance; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.socket.SocketChannel; -import io.netty.handler.codec.http.HttpObjectAggregator; -import io.netty.handler.codec.http.HttpRequestDecoder; -import io.netty.handler.codec.http.HttpResponseEncoder; -import io.netty.handler.timeout.ReadTimeoutHandler; -import io.netty.handler.timeout.WriteTimeoutHandler; - -public class HTTPServerInitializer extends ChannelInitializer { - - private CoinInstance coin; - - public HTTPServerInitializer(CoinInstance coin) { - this.coin = coin; - } - - @Override - protected void initChannel(SocketChannel ch) { - ChannelPipeline pipeline = ch.pipeline(); - - pipeline.addLast(new WriteTimeoutHandler(30)); - pipeline.addLast(new ReadTimeoutHandler(30)); - pipeline.addLast(new HttpRequestDecoder()); - pipeline.addLast(new HttpResponseEncoder()); - pipeline.addLast(new HttpObjectAggregator(100000000)); - pipeline.addLast(new HTTPServerHandler(coin)); - pipeline.addLast(new ExceptionHandler()); - } - -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/alqocoin/AlqocoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/alqocoin/AlqocoinNetworkParameters.java deleted file mode 100644 index a0ca792..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/alqocoin/AlqocoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.alqocoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class AlqocoinNetworkParameters extends NetworkParameters { - - public AlqocoinNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(100000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "XLQ"); - } - - @Override - public String getUriScheme() { - return "alqocoin:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70719; - } - - @Override - public int getAddressHeader() { - return 23; - } - - @Override - public int getP2SHHeader() { - return 16; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 193; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return 60; - } - - @Override - public String getId() { - return "XLQ"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/bitbay/BitbayNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/bitbay/BitbayNetworkParameters.java deleted file mode 100644 index b76e004..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/bitbay/BitbayNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.bitbay; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class BitbayNetworkParameters extends NetworkParameters { - - public BitbayNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(100000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "BAY"); - } - - @Override - public String getUriScheme() { - return "bitbay:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70719; - } - - @Override - public int getAddressHeader() { - return 25; - } - - @Override - public int getP2SHHeader() { - return 85; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 153; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return 60; - } - - @Override - public String getId() { - return "BAY"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/bitcoincash/BitcoinCashNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/bitcoincash/BitcoinCashNetworkParameters.java deleted file mode 100644 index e49ac66..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/bitcoincash/BitcoinCashNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.bitcoincash; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class BitcoinCashNetworkParameters extends NetworkParameters { - - public BitcoinCashNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return PAYMENT_PROTOCOL_ID_MAINNET; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return MAX_MONEY; - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "BCH"); - } - - @Override - public String getUriScheme() { - return "bch:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(final ProtocolVersion version) { - return version.getBitcoinProtocolVersion(); - } - - @Override - public int getAddressHeader() { - return 0; - } - - @Override - public int getP2SHHeader() { - return 5; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 128; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return INTERVAL; - } - - @Override - public String getId() { - return "BCH"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetBlockingClient.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetBlockingClient.java deleted file mode 100644 index 4c799b6..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetBlockingClient.java +++ /dev/null @@ -1,124 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import com.google.common.util.concurrent.SettableFuture; -import org.bitcoinj.core.Context; -import org.bitcoinj.net.MessageWriteTarget; -import org.bitcoinj.net.StreamConnection; - -import javax.annotation.Nullable; -import javax.net.SocketFactory; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.Socket; -import java.net.SocketAddress; -import java.nio.ByteBuffer; -import java.util.Set; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class BlocknetBlockingClient implements MessageWriteTarget { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private static final int BUFFER_SIZE_LOWER_BOUND = 4096; - private static final int BUFFER_SIZE_UPPER_BOUND = 65536; - - private Socket socket; - private volatile boolean closeRequested = false; - private SettableFuture connectFuture; - - public BlocknetBlockingClient(SocketAddress serverAddress, StreamConnection connection, int connectTimeoutMillis, SocketFactory socketFactory, @Nullable Set clientSet) throws IOException { - connectFuture = SettableFuture.create(); - - connection.setWriteTarget(this); - socket = socketFactory.createSocket(); - final Context context = Context.get(); - Thread t = new Thread(() -> { - Context.propagate(context); - if (clientSet != null) - clientSet.add(BlocknetBlockingClient.this); - try { - socket.connect(serverAddress, connectTimeoutMillis); - connection.connectionOpened(); - connectFuture.set(serverAddress); - InputStream stream = socket.getInputStream(); - runReadLoop(stream, connection); - } catch (Exception e) { - if (!closeRequested) { - LOGGER.log(Level.FINER, "[blocknet-blocking-client] Error trying to open/read from connection with " + serverAddress.toString() + "."); - e.printStackTrace(); - connectFuture.setException(e); - } - } finally { - try { - if (!socket.isClosed()) - socket.close(); - } catch (IOException e1) { - // At this point there isn't much we can do, and we can probably assume the channel is closed - } - if (clientSet != null) - clientSet.remove(BlocknetBlockingClient.this); - connection.connectionClosed(); - } - }); - t.setName("Blocknet network thread - " + serverAddress); - t.setDaemon(true); - t.start(); - } - - private static void runReadLoop(InputStream inputStream, StreamConnection connection) throws Exception { - ByteBuffer dbuf = ByteBuffer.allocateDirect(Math.min(Math.max(connection.getMaxMessageSize(), BUFFER_SIZE_LOWER_BOUND), BUFFER_SIZE_UPPER_BOUND)); - byte[] readBuff = new byte[dbuf.capacity()]; - - while (true) { - // TODO Kill the message duplication here - if (!(dbuf.remaining() > 0 && dbuf.remaining() <= readBuff.length)) { - throw new IllegalStateException(); - } - int read = inputStream.read(readBuff, 0, Math.max(1, Math.min(dbuf.remaining(), inputStream.available()))); - if (read == -1) - return; - dbuf.put(readBuff, 0, read); - - dbuf.flip(); - - int bytesConsumed = connection.receiveBytes(dbuf); - if (dbuf.position() != bytesConsumed) { - throw new IllegalStateException("Buffer did not stop reading at the correct location."); - } - - dbuf.compact(); - } - } - - @Override - public void closeConnection() { - try { - closeRequested = true; - socket.close(); - } catch (IOException e) { - LOGGER.log(Level.FINER, "[blocknet-blocking-client] Error while closing socket!"); - e.printStackTrace(); - } - } - - @Override - public synchronized void writeBytes(byte[] bytes) throws IOException { - try { - OutputStream out = socket.getOutputStream(); - out.write(bytes); - out.flush(); - } catch (IOException e) { - LOGGER.log(Level.FINER, "[blocknet-blocking-client] Error while writing bytes to socket!"); - e.printStackTrace(); - closeConnection(); - throw e; - } - } - - public SettableFuture getConnectFuture() { - return connectFuture; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetNetworkParameters.java deleted file mode 100644 index efd8f9e..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetNetworkParameters.java +++ /dev/null @@ -1,175 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.net.xrouter.XRouterMessageSerializer; -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -import java.math.BigInteger; - -public class BlocknetNetworkParameters extends BlocknetParameters { - - public BlocknetNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { - - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public Sha256Hash getGenesisBlockHash() { - return Sha256Hash.wrap("00000eb7919102da5a07dc90905651664e6ebf0811c28f06573b9a0fd84ab7b8"); - } - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(43199500).times(Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Coin.valueOf(5500); - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "BLOCK"); - } - - @Override - public String getUriScheme() { - return "blocknetdx:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BlocknetSerializer getSerializer(boolean parseRetain) { - return new BlocknetSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70712; - } - - /*@Override - public Block getGenesisBlock() { - return genesisBlock; - }*/ - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210000; - } - - @Override - public byte[] getAlertSigningKey() { - return Hex.decode("0415758705177c87c35dadf7ebf66e93ecc2710253bbac955e695664011fa39ff29a84fa21ae9e203a43debb487170c143ab6eaffe4fa3b12e162d8a6d4da87395"); - } - - @Override - public int getMajorityEnforceBlockUpgrade() { - return 750; - } - - @Override - public int getMajorityRejectBlockOutdated() { - return 950; - } - - @Override - public int getMajorityWindow() { - return 1000; - } - - @Override - public int getPort() { - return 41412; - } - - @Override - public long getPacketMagic() { - return 0xA1A0A2A3; - } - - @Override - public String[] getDnsSeeds() { - int nodeCount = 5; - - String[] dnsSeeds = new String[nodeCount]; - - for (int i = 0; i < nodeCount; i++) - dnsSeeds[i] = "node-" + i + ".cloudchainsinc.com"; - - return dnsSeeds; - } - - @Override - public int getInterval() { - return 1; - } - - @Override - public int getAddressHeader() { - return 26; - } - - @Override - public int getP2SHHeader() { - return 28; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 154; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public BigInteger getMaxTarget() { - return Utils.decodeCompactBits(0x1E0FFFFF); - } - - @Override - public int getTargetTimespan() { - return 60; - } - - @Override - public String getId() { - return "BLOCK"; - } - - @Override - public XRouterMessageSerializer getXRouterMessageSerializer(boolean parseRetain) { - return new XRouterMessageSerializer(parseRetain, this); - } - -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPacketHeader.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPacketHeader.java deleted file mode 100644 index 80796e5..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPacketHeader.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import com.subgraph.orchid.encoders.Hex; -import org.bitcoinj.core.BitcoinSerializer; -import org.bitcoinj.core.Message; -import org.bitcoinj.core.ProtocolException; -import org.bitcoinj.core.Utils; - -import java.nio.BufferUnderflowException; -import java.nio.ByteBuffer; - -public class BlocknetPacketHeader extends BitcoinSerializer.BitcoinPacketHeader { - - public static final int HEADER_LENGTH = 20; - - private String command; - private byte[] checksum; - private int length; - - public BlocknetPacketHeader(ByteBuffer in) throws ProtocolException, BufferUnderflowException { - super(in); - in.position(in.position() - BitcoinSerializer.BitcoinPacketHeader.HEADER_LENGTH); - byte[] header = new byte[HEADER_LENGTH]; - in.get(header, 0, header.length); - - int cursor = 0; - - for (; header[cursor] != 0x00 && cursor < 12; cursor++); - - byte[] commandBytes = new byte[cursor]; - System.arraycopy(header, 0, commandBytes, 0, cursor); - cursor = 12; - - command = new String(commandBytes).trim(); -// LOGGER.log(Level.FINER, "[blocknet-header] Retrieved command: " + command); - - length = (int) Utils.readUint32(header, cursor); -// LOGGER.log(Level.FINER, "[blocknet-header] Retrieved length: " + length); - cursor += 4; - - if (length > Message.MAX_SIZE || length < 0) { - throw new ProtocolException("Message too large or negative length: " + length); - } - - checksum = new byte[4]; - System.arraycopy(header, cursor, checksum, 0, 4); -// LOGGER.log(Level.FINER, "[blocknet-header] Retrieved checksum: " + new String(Hex.encode(checksum))); - } - - public String getCommand() { - return command; - } - - public void setChecksum(byte[] checksum) { - this.checksum = checksum; - } - - public byte[] getChecksum() { - return checksum; - } - - public void setLength(int length) { - this.length = length; - } - - public int getLength() { - return length; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetParameters.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetParameters.java deleted file mode 100644 index 1fae6aa..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetParameters.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import io.cloudchains.app.net.xrouter.XRouterMessageSerializer; -import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.core.Sha256Hash; - -public abstract class BlocknetParameters extends NetworkParameters { - - public abstract XRouterMessageSerializer getXRouterMessageSerializer(boolean parseRetain); - - public abstract Sha256Hash getGenesisBlockHash(); - - @Override - public abstract BlocknetSerializer getSerializer(boolean parseRetain); -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPeer.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPeer.java deleted file mode 100644 index 939a5e8..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPeer.java +++ /dev/null @@ -1,748 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import com.google.common.base.Function; -import com.google.common.base.Throwables; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.SettableFuture; -import io.cloudchains.app.Version; -import io.cloudchains.app.net.protocols.blocknet.listeners.*; -import io.cloudchains.app.net.protocols.blocknet.messages.VersionMessageImpl; -import io.cloudchains.app.net.xrouter.XRouterCommandUtils; -import io.cloudchains.app.net.xrouter.XRouterInitialMessagesSentListener; -import io.cloudchains.app.net.xrouter.XRouterMessage; -import io.cloudchains.app.net.xrouter.XRouterMessageSerializer; -import io.cloudchains.app.util.XRouterConfiguration; -import net.jcip.annotations.GuardedBy; -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.ListenerRegistration; -import org.bitcoinj.utils.Threading; -import org.bitcoinj.wallet.Wallet; -import org.json.JSONObject; -import javax.annotation.Nullable; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.BufferUnderflowException; -import java.nio.ByteBuffer; -import java.nio.channels.NotYetConnectedException; -import java.util.*; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.locks.ReentrantLock; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class BlocknetPeer extends PeerSocketHandler { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private final ReentrantLock lock = Threading.lock("BlocknetPeer"); - - private boolean activePeer; - private boolean hasRequiredPlugins; - - private boolean pastConnectionSuccess; - - private BlocknetParameters params; - private BlocknetSerializer serializer; - private XRouterMessageSerializer xRouterMessageSerializer; - private AbstractBlockChain blockChain; - - private BlocknetSeed blocknetSeed; - private XRouterConfiguration xRouterConfiguration; - private final ArrayList pluginConfigurations = new ArrayList<>(); - private final AtomicBoolean haveConfig = new AtomicBoolean(false); - - - private Context context; - private CopyOnWriteArrayList messagesPendingReply = new CopyOnWriteArrayList<>(); - - private CopyOnWriteArrayList> initialMessagesSentListeners = new CopyOnWriteArrayList<>(); - private CopyOnWriteArrayList> preMessageReceivedEventListeners = new CopyOnWriteArrayList<>(); - private CopyOnWriteArrayList> peerConnectedEventListeners = new CopyOnWriteArrayList<>(); - private CopyOnWriteArrayList> blocksDownloadedEventListeners = new CopyOnWriteArrayList<>(); - private CopyOnWriteArrayList> disconnectedEventListeners = new CopyOnWriteArrayList<>(); - private CopyOnWriteArrayList> xRouterMessageListeners = new CopyOnWriteArrayList<>(); - - private volatile boolean downloadData; - - @GuardedBy("lock") - private boolean downloadBlockBodies; - - private static class GetDataRequest { - final Sha256Hash hash; - final SettableFuture future; - - public GetDataRequest(Sha256Hash hash, SettableFuture future) { - this.hash = hash; - this.future = future; - } - } - - private final CopyOnWriteArrayList getDataFutures = new CopyOnWriteArrayList<>(); - - @GuardedBy("lock") - private Sha256Hash lastGetBlocksBegin, lastGetBlocksEnd; - - private final CopyOnWriteArrayList pendingPings = new CopyOnWriteArrayList<>(); - private VersionMessage peerVersionMessage = null; - private final VersionMessage ourVersionMessage; - private CopyOnWriteArrayList wallets = new CopyOnWriteArrayList<>(); - private volatile int downloadTxDependencyDepth; - - private final SettableFuture connectionOpenFuture = SettableFuture.create(); - private final SettableFuture incomingVersionHandshakeFuture = SettableFuture.create(); - private final SettableFuture outgoingVersionHandshakeFuture = SettableFuture.create(); - private final SettableFuture incomingPingHandshakeFuture = SettableFuture.create(); - private boolean firstPingReceived = false; - - @SuppressWarnings({"UnstableApiUsage", "unchecked"}) - private final ListenableFuture versionHandshakeFuture = Futures.transform(Futures.allAsList(outgoingVersionHandshakeFuture, - incomingVersionHandshakeFuture, - incomingPingHandshakeFuture), - new Function, BlocknetPeer>() { - @Nullable - @Override - public BlocknetPeer apply(@Nullable List peers) { - if (peers == null) { - throw new NullPointerException("Peer list is null."); - } - - if (peers.size() != 2 || peers.get(0) != peers.get(1)) { - throw new IllegalStateException("Bad peer list state."); - } - - return peers.get(0); - } - }, Threading.SAME_THREAD); - - private FilteredBlock currentFilteredBlock; - private final HashSet pendingBlockDownloads = new HashSet<>(); - - @GuardedBy("lock") - @Nullable - private List awaitingFreshFilter; - - private static final int minProtocolVersion = 70712; - - private AtomicReference largeReadBuffer = new AtomicReference<>(); - private AtomicInteger largeReadBufferPos = new AtomicInteger(); - private AtomicReference header; - - protected BlocknetPeer(BlocknetParameters params, AbstractBlockChain chain, PeerAddress peerAddress, BlocknetSeed blocknetSeed) { - super(params, peerAddress); - - this.params = params; - this.serializer = this.params.getSerializer(false); - this.xRouterMessageSerializer = this.params.getXRouterMessageSerializer(false); - - this.blockChain = chain; - this.downloadData = chain != null; - this.downloadTxDependencyDepth = chain != null ? Integer.MAX_VALUE : 0; - this.peerAddress = peerAddress; - - this.blocknetSeed = blocknetSeed; - - this.versionHandshakeFuture.addListener(this::versionHandshakeComplete, Threading.SAME_THREAD); - - this.context = Context.getOrCreate(params); - - this.ourVersionMessage = new VersionMessageImpl(this.params, chain != null ? chain.getBestChainHeight() : 0); - this.ourVersionMessage.appendToSubVer(Version.CLIENT_TYPE, Version.CLIENT_VERSION, Version.CLIENT_COMMENTS); - - LOGGER.log(Level.FINER, "[blocknet-peer] DEBUG: Our version message:"); - LOGGER.log(Level.FINER, this.ourVersionMessage.toString()); - - this.activePeer = true; - this.pastConnectionSuccess = false; - } - - @Override - public void connectionClosed() { - if (!activePeer) return; - - activePeer = false; - LOGGER.log(Level.FINER, "[blocknet-peer] Connection with " + (getAddress() != null ? getAddress().toString() : "") + " closed. Notifying receivers."); - - for (final ListenerRegistration registration : disconnectedEventListeners) { - registration.executor.execute(() -> registration.listener.onPeerDisconnected(BlocknetPeer.this, 0)); - } - } - - @Override - public void connectionOpened() { - LOGGER.log(Level.FINER, "[blocknet-peer] Connection open to " + (getAddress() != null ? getAddress().toString() : "") + ", sending version message."); - - sendMessage(ourVersionMessage); - connectionOpenFuture.set(this); - } - - @Override - protected void timeoutOccurred() { - super.timeoutOccurred(); - LOGGER.log(Level.FINER, "[blocknet-peer] Timeout occurred."); - if (!connectionOpenFuture.isDone()) { - connectionClosed(); - } - } - - public void addPreMessageReceivedEventListener(BlocknetPreMessageReceivedEventListener listener) { - addPreMessageReceivedEventListener(Threading.SAME_THREAD, listener); - } - - private void addPreMessageReceivedEventListener(Executor executor, BlocknetPreMessageReceivedEventListener listener) { - preMessageReceivedEventListeners.add(new ListenerRegistration<>(listener, executor)); - } - - public void addConnectedEventListener(BlocknetPeerConnectedEventListener listener) { - addConnectedEventListener(Threading.SAME_THREAD, listener); - } - - public void addConnectedEventListener(Executor executor, BlocknetPeerConnectedEventListener listener) { - peerConnectedEventListeners.add(new ListenerRegistration<>(listener, executor)); - } - - public void addBlocksDownloadedEventListener(BlocknetOnBlocksDownloadedEventListener listener) { - addBlocksDownloadedEventListener(Threading.SAME_THREAD, listener); - } - - private void addBlocksDownloadedEventListener(Executor executor, BlocknetOnBlocksDownloadedEventListener listener) { - blocksDownloadedEventListeners.add(new ListenerRegistration<>(listener, executor)); - } - - public void addPeerDisconnectedEventListener(BlocknetPeerDisconnectedEventListener listener) { - addPeerDisconnectedEventListener(Threading.SAME_THREAD, listener); - } - - public void addPeerDisconnectedEventListener(Executor executor, BlocknetPeerDisconnectedEventListener listener) { - disconnectedEventListeners.add(new ListenerRegistration<>(listener, executor)); - } - - public void addXRouterMessageReceivedEventListener(BlocknetOnXRouterMessageReceivedListener listener) { - addXRouterMessageReceivedEventListener(Threading.SAME_THREAD, listener); - } - - private void addXRouterMessageReceivedEventListener(Executor executor, BlocknetOnXRouterMessageReceivedListener listener) { - xRouterMessageListeners.add(new ListenerRegistration<>(listener, executor)); - } - - public void removeXRouterMessageReceivedEventListener(BlocknetOnXRouterMessageReceivedListener listener) { - ListenerRegistration.removeFromList(listener, xRouterMessageListeners); - } - - public BlocknetOnXRouterMessageReceivedListener getListener(final String uuid, final AtomicReference response, final CountDownLatch latch) { - return new BlocknetOnXRouterMessageReceivedListener() { - @Override - public void onXRouterMessageReceived(XRouterMessage message, XRouterMessage original) { - if (message.getXRouterHeader().getUUID().equals(uuid)) { - response.set((String) message.getParsedData().get("reply")); - - latch.countDown(); - - removeXRouterMessageReceivedEventListener(this); - } - } - }; - } - - @Override - public void sendMessage(Message message) throws NotYetConnectedException { - lock.lock(); - try { - if (writeTarget == null) { - LOGGER.log(Level.FINER, "[blocknet-peer] ERROR: Attempted to send message on non-connected socket."); - throw new NotYetConnectedException(); - } - } finally { - lock.unlock(); - } - - if (message instanceof XRouterMessage) { - try { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - xRouterMessageSerializer.serialize(message, outputStream); - LOGGER.log(Level.FINER, "[blocknet-peer] DEBUG: Sending XRouter message. Actual length (excluding network header) is " + (outputStream.size() - BlocknetPacketHeader.HEADER_LENGTH - 4) + " bytes."); - writeTarget.writeBytes(outputStream.toByteArray()); - - messagesPendingReply.add((XRouterMessage) message); - LOGGER.log(Level.FINER, "[blocknet-peer] DEBUG: Added UUID " + ((XRouterMessage) message).getXRouterHeader().getUUID() + " to pending reply list."); - } catch (IOException e) { - LOGGER.log(Level.FINER, "[blocknet-peer] Error while serializing XRouter message!"); - e.printStackTrace(); - } - } else { - try { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - serializer.serialize(message, outputStream); - writeTarget.writeBytes(outputStream.toByteArray()); - } catch (IOException e) { - LOGGER.log(Level.FINER, "[blocknet-peer] Error while serializing/sending non-XRouter message!"); - e.printStackTrace(); - } - } - } - - @Override - protected void processMessage(Message message) { - for (ListenerRegistration registration : preMessageReceivedEventListeners) { - if (registration.executor == Threading.SAME_THREAD) { - message = registration.listener.onPreMessageReceived(this, message); - } - - if (message == null) - break; - } - - if (message == null) - return; - - if (currentFilteredBlock != null && !(message instanceof Transaction)) { - endFilteredBlock(currentFilteredBlock); - currentFilteredBlock = null; - } - - if (!(message instanceof VersionMessage || message instanceof Ping || message instanceof VersionAck || (versionHandshakeFuture.isDone() && !versionHandshakeFuture.isCancelled()))) { - throw new ProtocolException("Received " + message.getClass().getSimpleName() + " before version handshake was complete."); - } - - if (message instanceof VersionMessage) { - processVersionMessage((VersionMessage) message); - } else if (message instanceof VersionAck) { - processVersionAck((VersionAck) message); - } else if (message instanceof Ping) { - LOGGER.log(Level.FINER, "[blocknet-peer] Received ping message from " + getAddress().toString() + ", sending pong."); - processPing((Ping) message); - } else if (message instanceof RejectMessage) { - LOGGER.log(Level.FINER, "[blocknet-peer] ERROR: Received rejection message from " + getAddress().toString() + ": " + message.toString()); - } else if (message instanceof XRouterMessage) { - processXRouterMessage((XRouterMessage) message); - } else { - LOGGER.log(Level.FINER, "[blocknet-peer] Warning: Received unhandled message from " + getAddress().toString() + ": " + message.toString()); - } - - //TODO process other message types - } - - private void processPing(Ping pingMessage) throws ProtocolException { - sendMessage(new Pong(pingMessage.getNonce())); - - if (!firstPingReceived) { - firstPingReceived = true; - incomingPingHandshakeFuture.set(this); - } - } - - private void processVersionMessage(VersionMessage versionMessage) throws ProtocolException { - if (peerVersionMessage != null) { - throw new ProtocolException("Received more than one version message from this peer!"); - } - - peerVersionMessage = versionMessage; - - LOGGER.log(Level.FINER, "[blocknet-peer] Received version message: " + peerVersionMessage.subVer - + ", version " + peerVersionMessage.clientVersion - + ", blocks=" + peerVersionMessage.bestHeight - + ", us=" + peerVersionMessage.theirAddr); - - if (!peerVersionMessage.hasBlockChain() || (!params.allowEmptyPeerChain() && peerVersionMessage.bestHeight == 0)) { - LOGGER.log(Level.FINER, "[blocknet-peer] ERROR: Peer has an empty blockchain while this network does not allow empty blockchains. Disconnecting."); - close(); - } - - if (peerVersionMessage.bestHeight < 0) { - LOGGER.log(Level.FINER, "[blocknet-peer] ERROR: Peer reported bad blockchain height (" + peerVersionMessage.bestHeight + "). Disconnecting."); - close(); - } - - sendMessage(new VersionAck()); - LOGGER.log(Level.FINER, "[blocknet-peer] Incoming version handshake complete."); - incomingVersionHandshakeFuture.set(this); - } - - private void processVersionAck(VersionAck versionAck) throws ProtocolException { - if (peerVersionMessage == null) { - throw new ProtocolException("Received version acknowledgement before version message."); - } - - if (!incomingVersionHandshakeFuture.isDone()) { - throw new ProtocolException("Received more than one version acknowledgement."); - } - - LOGGER.log(Level.FINER, "[blocknet-peer] Outgoing version handshake complete."); - outgoingVersionHandshakeFuture.set(this); - } - - private void versionHandshakeComplete() { - setTimeoutEnabled(false); - for (final ListenerRegistration registration : peerConnectedEventListeners) { - registration.executor.execute(() -> registration.listener.onPeerConnected(BlocknetPeer.this, 1)); - } - - if (peerVersionMessage.clientVersion < minProtocolVersion) { - LOGGER.log(Level.FINER, "[blocknet-peer] Peer's protocol version (" + peerVersionMessage.clientVersion + ") is lower than the minimum (" + minProtocolVersion + ")! Disconnecting."); - close(); - } - } - - private XRouterMessage getOriginalXRouterMessage(String uuid) { - for (XRouterMessage msg : messagesPendingReply) { - if (msg.getXRouterHeader().getUUID().equalsIgnoreCase(uuid)) - return msg; - } - - return null; - } - - private int removeUUIDFromPendingReplyList(String uuid) { - int removed = 0; - - for (XRouterMessage msg : messagesPendingReply) { - if (msg.getXRouterHeader().getUUID().equalsIgnoreCase(uuid)) { - messagesPendingReply.remove(msg); - removed++; - } - } - - return removed; - } - - private void processReply(XRouterMessage message) { - if (message.getXRouterHeader().getUUID().isEmpty()) { - LOGGER.log(Level.FINER, "[blocknet-peer] ERROR: XRouter server sent back packet with blank UUID!"); - return; - } - - final XRouterMessage original = getOriginalXRouterMessage(message.getXRouterHeader().getUUID()); - if (original == null) { - LOGGER.log(Level.FINER, "[blocknet-peer] ERROR: Unexpected UUID in reply message! Perhaps the server thinks we sent a packet that we didn't send?"); - throw new ProtocolException("Unexpected UUID in reply message"); - } - - int removed = removeUUIDFromPendingReplyList(message.getXRouterHeader().getUUID()); - if (removed != 1) { - LOGGER.log(Level.FINER, "[blocknet-peer] Warning: Exception occurred while removing message from pending list! This may break things later on. Amount of messages removed = " + removed); - if (removed == 0) { - LOGGER.log(Level.FINER, "[blocknet-peer] ERROR: Invalid UUID in reply message!"); - throw new ProtocolException("Invalid UUID in reply message"); - } - } - - LOGGER.log(Level.FINER, "[blocknet-peer] XRouter pre-processing successful. Notifying listeners."); - for (ListenerRegistration registration : xRouterMessageListeners) { - if (registration.executor == Threading.SAME_THREAD) { - registration.executor.execute(() -> registration.listener.onXRouterMessageReceived(message, original)); - } - } - } - - private void processXRouterMessage(final XRouterMessage message) { - LOGGER.log(Level.FINER, "processXRouterMessage() called."); - LOGGER.log(Level.FINER, "This XRouter message's UUID is '" + message.getXRouterHeader().getUUID() + "'"); - - switch (XRouterCommandUtils.commandIdToString(message.getXRouterHeader().getCommand())) { - case "xrReply": //xrReply - case "xrConfigReply": { // xrConfigReply - processReply(message); - break; - } - default: { //xrInvalid or unexpected message - for (ListenerRegistration registration : xRouterMessageListeners) { - if (registration.executor == Threading.SAME_THREAD) { - registration.executor.execute(() -> registration.listener.onXRouterMessageReceived(message, null)); - } - } - break; - } - } - - } - - @GuardedBy("lock") - private void blockChainDownloadLocked(Sha256Hash toHash) { - if (!lock.isHeldByCurrentThread()) { - throw new IllegalStateException("Lock is not held by current thread."); - } - - List blockLocator = new ArrayList<>(51); - - if (blockChain == null) { - throw new NullPointerException("Blockchain object is null."); - } - - BlockStore blockStore = blockChain.getBlockStore(); - StoredBlock chainHead = blockChain.getChainHead(); - Sha256Hash chainHeadHash = chainHead.getHeader().getHash(); - - if (Objects.equals(chainHeadHash, lastGetBlocksBegin) || Objects.equals(toHash, lastGetBlocksEnd)) { - LOGGER.log(Level.FINER, "[blocknet-peer] Ignoring dupliated request: chainHeadHash = " + chainHeadHash.toString() + ", toHash = " + toHash.toString()); - - for (Sha256Hash hash : pendingBlockDownloads) - LOGGER.log(Level.FINER, "[blocknet-peer] Pending block download: " + hash.toString()); - - LOGGER.log(Level.FINER, Throwables.getStackTraceAsString(new Throwable())); - return; - } - - LOGGER.log(Level.FINER, "[blocknet-peer] blockChainDownloadLocked(" + toHash.toString() + "): Current head = " + chainHeadHash.toString()); - - StoredBlock cursor = chainHead; - for (int i = 100; cursor != null && i > 0; i--) { - blockLocator.add(cursor.getHeader().getHash()); - try { - cursor = cursor.getPrev(blockStore); - } catch (BlockStoreException e) { - LOGGER.log(Level.FINER, "[blocknet-peer] Failed to walk the blockchain while constructing a locator."); - e.printStackTrace(); - } - } - - if (cursor != null) - blockLocator.add(params.getGenesisBlockHash()); - - lastGetBlocksBegin = chainHeadHash; - lastGetBlocksEnd = toHash; - - if (downloadBlockBodies) { - GetBlocksMessage getBlocksMessage = new GetBlocksMessage(params, blockLocator, toHash); - sendMessage(getBlocksMessage); - } else { - GetHeadersMessage getHeadersMessage = new GetHeadersMessage(params, blockLocator, toHash); - sendMessage(getHeadersMessage); - } - } - - private void endFilteredBlock(FilteredBlock filteredBlock) { - if (!downloadData) { - LOGGER.log(Level.FINER, "[blocknet-peer] WARNING: [" + getAddress().toString() + "] Received block we did not ask for! Hash: " + filteredBlock.getHash().toString()); - return; - } - - if (blockChain == null) { - LOGGER.log(Level.FINER, "[blocknet-peer] WARNING: Received a block, but a blockchain object was not configured!"); - return; - } - - pendingBlockDownloads.remove(filteredBlock.getBlockHeader().getHash()); - try { - lock.lock(); - - try { - if (awaitingFreshFilter != null) { - LOGGER.log(Level.FINER, "[blocknet-peer] Discarding this block because we are waiting for a fresh filter. Hash: " + filteredBlock.getHash().toString()); - - awaitingFreshFilter.add(filteredBlock.getHash()); - return; - } else if (checkForFilterExhaustion(filteredBlock)) { - awaitingFreshFilter = new LinkedList<>(); - awaitingFreshFilter.add(filteredBlock.getHash()); - awaitingFreshFilter.addAll(blockChain.drainOrphanBlocks()); - return; - } - } finally { - lock.unlock(); - } - - if (blockChain.add(filteredBlock)) { - invokeOnBlocksDownloaded(filteredBlock.getBlockHeader(), filteredBlock); - } else { - lock.lock(); - try { - final Block orphanRoot = blockChain.getOrphanRoot(filteredBlock.getHash()); - if (orphanRoot == null) { - throw new NullPointerException("Orphan root is null."); - } - - blockChainDownloadLocked(orphanRoot.getHash()); - } finally { - lock.unlock(); - } - } - } catch (VerificationException e) { - LOGGER.log(Level.FINER, "[blocknet-peer] Block failed to properly verify!"); - e.printStackTrace(); - } catch (PrunedException e) { - LOGGER.log(Level.FINER, "[blocknet-peer] Some data needed to handle this block was pruned! Hash: " + filteredBlock.getHash().toString()); - throw new RuntimeException(e); - } - } - - private boolean checkForFilterExhaustion(FilteredBlock filteredBlock) { - boolean exhausted = false; - for (Wallet wallet : wallets) { - exhausted |= wallet.checkForFilterExhaustion(filteredBlock); - } - return exhausted; - } - - private void invokeOnBlocksDownloaded(final Block block, @Nullable final FilteredBlock filteredBlock) { - if (blockChain == null) { - return; - } - - final int blocksLeft = Math.max(0, (int) peerVersionMessage.bestHeight - blockChain.getBestChainHeight()); - for (final ListenerRegistration registration : blocksDownloadedEventListeners) { - registration.executor.execute(() -> registration.listener.onBlocksDownloaded(BlocknetPeer.this, block, filteredBlock, blocksLeft)); - } - } - - @Override - public int receiveBytes(ByteBuffer buff) { - if (buff.position() != 0 || buff.capacity() < BlocknetPacketHeader.HEADER_LENGTH + 4) { - throw new IllegalArgumentException("Buffer position is nonzero or bad header."); - } - - try { - boolean firstMessage = true; - - while (true) { - if (largeReadBuffer.get() != null) { - if (!firstMessage) { - throw new IllegalStateException("Bad firstMessage state."); - } - - int bytesToGet = Math.min(buff.remaining(), largeReadBuffer.get().length - largeReadBufferPos.get()); - byte[] buf = largeReadBuffer.get(); - buff.get(buf, largeReadBufferPos.get(), bytesToGet); - - largeReadBuffer.set(buf); - - largeReadBufferPos.set(largeReadBufferPos.get() + bytesToGet); - - if (largeReadBufferPos.get() == largeReadBuffer.get().length) { - processMessage(serializer.deserializePayload(header.get(), ByteBuffer.wrap(largeReadBuffer.get()))); - - largeReadBuffer.set(null); - header = null; - firstMessage = false; - } else { - return buff.position(); - } - } - - Message message; - int preSerializePos = buff.position(); - try { - message = serializer.deserialize(buff); - } catch (BufferUnderflowException e) { - if (firstMessage && buff.limit() == buff.capacity()) { - buff.position(0); - - try { - serializer.seekPastMagicBytes(buff); - header.set(serializer.deserializeHeader(buff)); - - largeReadBufferPos.set(buff.remaining()); - byte[] buf = new byte[header.get().getLength()]; - buff.get(buf, 0, largeReadBufferPos.get()); - largeReadBuffer.set(buf); - } catch (BufferUnderflowException e1) { - throw new ProtocolException("No magic/header after reading " + buff.capacity() + " bytes."); - } - } else { - buff.position(preSerializePos); - } - - return buff.position(); - } - - processMessage(message); - firstMessage = false; - } - } catch (Exception e) { - LOGGER.log(Level.FINER, "Error while receiving bytes!"); - e.printStackTrace(); - return -1; - } - } - - public void parsePlugins(JSONObject pluginsList) { - for (String plugin : pluginsList.keySet()) { - String rawPluginConfig = pluginsList.getString(plugin); - - XRouterConfiguration.XRouterPluginConfiguration pluginConfig = new XRouterConfiguration.XRouterPluginConfiguration(plugin, rawPluginConfig); - pluginConfig.parsePluginConfig(); - getPluginConfigurations().add(pluginConfig); - } - } - - public XRouterConfiguration.XRouterPluginConfiguration getPluginConfig(String pluginName) { - for (XRouterConfiguration.XRouterPluginConfiguration pluginConfig : pluginConfigurations) { - if (pluginConfig.getPluginName().equals(pluginName)) { - return pluginConfig; - } - } - - return null; - } - - public CopyOnWriteArrayList getMessagesPendingReply() { - return messagesPendingReply; - } - - public CopyOnWriteArrayList> getInitialMessagesSentListeners() { - return initialMessagesSentListeners; - } - - public CopyOnWriteArrayList> getXRouterMessageListeners() { - return xRouterMessageListeners; - } - - public void addInitialMessagesSentListener(XRouterInitialMessagesSentListener listener) { - initialMessagesSentListeners.add(new ListenerRegistration<>(listener, Threading.SAME_THREAD)); - } - - public void removeInitialMessagesSentListener(XRouterInitialMessagesSentListener listener) { - ListenerRegistration.removeFromList(listener, initialMessagesSentListeners); - } - - public BlocknetSeed getBlocknetSeed() { - return blocknetSeed; - } - - public AtomicBoolean getHaveConfig() { - return haveConfig; - } - - public XRouterConfiguration getxRouterConfiguration() { - return xRouterConfiguration; - } - - public ArrayList getPluginConfigurations() { - return pluginConfigurations; - } - - public boolean isActivePeer() { - return activePeer; - } - - public boolean pastConnectionSuccess() { - return pastConnectionSuccess; - } - - public boolean hasRequiredPlugins() { - return hasRequiredPlugins; - } - - public void setPastConnectionSuccess(boolean pastConnectionSuccess) { - this.pastConnectionSuccess = pastConnectionSuccess; - } - - public void setHaveConfig(boolean hasConfig) { - this.haveConfig.set(hasConfig); - } - - public void setxRouterConfiguration(XRouterConfiguration xRouterConfiguration) { - this.xRouterConfiguration = xRouterConfiguration; - } - - public void setHasRequiredPlugins(boolean hasRequiredPlugins) { - this.hasRequiredPlugins = hasRequiredPlugins; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetSerializer.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetSerializer.java deleted file mode 100644 index 28a773a..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetSerializer.java +++ /dev/null @@ -1,219 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.net.protocols.blocknet.messages.VersionMessageImpl; -import io.cloudchains.app.net.xrouter.XRouterMessage; -import org.bitcoinj.core.*; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.BufferUnderflowException; -import java.nio.ByteBuffer; -import java.util.HashMap; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class BlocknetSerializer extends BitcoinSerializer { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private BlocknetParameters params; - private boolean parseRetain; - - private static final HashMap, String> messageNames = new HashMap<>(); - - static { - messageNames.put(VersionMessage.class, "version"); - messageNames.put(VersionMessageImpl.class, "version"); - messageNames.put(InventoryMessage.class, "inv"); - messageNames.put(Block.class, "block"); - messageNames.put(GetDataMessage.class, "getdata"); - messageNames.put(Transaction.class, "tx"); - messageNames.put(AddressMessage.class, "addr"); - messageNames.put(Ping.class, "ping"); - messageNames.put(Pong.class, "pong"); - messageNames.put(VersionAck.class, "verack"); - messageNames.put(GetBlocksMessage.class, "getblocks"); - messageNames.put(GetHeadersMessage.class, "getheaders"); - messageNames.put(GetAddrMessage.class, "getaddr"); - messageNames.put(HeadersMessage.class, "headers"); - messageNames.put(BloomFilter.class, "filterload"); - messageNames.put(FilteredBlock.class, "merkleblock"); - messageNames.put(NotFoundMessage.class, "notfound"); - messageNames.put(MemoryPoolMessage.class, "mempool"); - messageNames.put(RejectMessage.class, "reject"); - messageNames.put(GetUTXOsMessage.class, "getutxos"); - messageNames.put(UTXOsMessage.class, "utxos"); - } - - public BlocknetSerializer(NetworkParameters params, boolean parseRetain) { - super(params, parseRetain); - - if (!(params instanceof BlocknetParameters)) { - throw new IllegalArgumentException("Invalid network parameters for Blocknet."); - } - - this.params = (BlocknetParameters) params; - this.parseRetain = parseRetain; - } - - @Override - public BlocknetPacketHeader deserializeHeader(ByteBuffer in) throws ProtocolException { - //in.position(0); - //seekPastMagicBytes(in); - return new BlocknetPacketHeader(in); - } - - @Override - public Message deserializePayload(BitcoinSerializer.BitcoinPacketHeader header, ByteBuffer in) throws ProtocolException, BufferUnderflowException { - //in.position(0); - //seekPastMagicBytes(in); - BlocknetPacketHeader blocknetPacketHeader = (BlocknetPacketHeader) header; - - byte[] payloadBytes = new byte[blocknetPacketHeader.getLength()]; - in.get(payloadBytes, 0, payloadBytes.length); - - if (!BlocknetUtils.verifyChecksum(blocknetPacketHeader, payloadBytes)) { - throw new ProtocolException("Checksum failed to verify."); - } - - switch (blocknetPacketHeader.getCommand().toLowerCase()) { - case "xrouter": - LOGGER.log(Level.FINER, "[blocknet-serializer] Received XRouter packet, at position: " + in.position()); - return new XRouterMessage(params, payloadBytes); - case "version": -// LOGGER.log(Level.FINER, "[blocknet-serializer] Version message received"); - return new VersionMessage(params, payloadBytes); - case "inv": -// LOGGER.log(Level.FINER, "[blocknet-serializer] Warning: Inventory messages are ignored"); - return null; - case "block": - return new Block(params, payloadBytes, 0, this, blocknetPacketHeader.getLength()); - case "merkleblock": - return new FilteredBlock(params, payloadBytes); - case "getdata": - return new GetDataMessage(params, payloadBytes, this, blocknetPacketHeader.getLength()); - case "getblocks": - return new GetBlocksMessage(params, payloadBytes); - case "getheaders": - return new GetHeadersMessage(params, payloadBytes); - case "tx": - return new Transaction(params, payloadBytes, 0, null, this, blocknetPacketHeader.getLength()); - case "addr": - return makeAddressMessage(payloadBytes, blocknetPacketHeader.getLength()); - case "alert": - return new AlertMessage(params, payloadBytes); - case "ping": - return new Ping(params, payloadBytes); - case "pong": - return new Pong(params, payloadBytes); - case "verack": - return new VersionAck(params, payloadBytes); - case "headers": - return new HeadersMessage(params, payloadBytes); - case "filterload": - return new BloomFilter(params, payloadBytes); - case "notfound": - return new NotFoundMessage(params, payloadBytes); - case "mempool": - return new MemoryPoolMessage(); - case "reject": - return new RejectMessage(params, payloadBytes); - case "utxos": - return new UTXOsMessage(params, payloadBytes); - case "getutxos": - return new GetUTXOsMessage(params, payloadBytes); - case "getsporks": - case "ssc": - case "mnget": - case "xbridge": -// LOGGER.log(Level.FINER, "[blocknet-serializer] Warning: This serializer does not support deserializing xbridge/ssc/mnget/getsporks packets yet."); - return null; - case "dseg": -// LOGGER.log(Level.FINER, "[blocknet-serializer] Warning: This serializer does not support deserializing dseg packets yet."); - return null; - default: - LOGGER.log(Level.FINER, "[blocknet-serializer] Warning: This serializer does not support deserializing " + blocknetPacketHeader.getCommand() + " packets (yet)."); - return new UnknownMessage(params, blocknetPacketHeader.getCommand(), payloadBytes); - } - } - - @Override - public Message deserialize(ByteBuffer in) throws ProtocolException { - //in.position(0); - seekPastMagicBytes(in); - BlocknetPacketHeader header = new BlocknetPacketHeader(in); - - return deserializePayload(header, in); - } - - @Override - public boolean isParseRetainMode() { - return parseRetain; - } - - @Override - public AddressMessage makeAddressMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { - return null; - } - - @Override - public Message makeAlertMessage(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { - return new AlertMessage(params, payloadBytes); - } - - @Override - public Block makeBlock(byte[] payloadBytes, int offset, int length) throws ProtocolException, UnsupportedOperationException { - return new Block(params, payloadBytes, offset, this, length); - } - - @Override - public Message makeBloomFilter(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { - return new BloomFilter(params, payloadBytes); - } - - @Override - public FilteredBlock makeFilteredBlock(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { - return new FilteredBlock(params, payloadBytes); - } - - @Override - public InventoryMessage makeInventoryMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { - return new InventoryMessage(params, payloadBytes, this, length); - } - - @Override - public Transaction makeTransaction(byte[] payloadBytes, int offset, int length, byte[] hash) throws ProtocolException, UnsupportedOperationException { - return new Transaction(params, payloadBytes, offset, null, this, length); - } - - @Override - public void seekPastMagicBytes(ByteBuffer in) throws BufferUnderflowException { - BlocknetUtils.seekPastMagicBytes(in, params); - } - - @Override - public void serialize(String name, byte[] message, OutputStream out) throws IOException, UnsupportedOperationException { - byte[] header = BlocknetUtils.getHeader(name, (int) params.getPacketMagic(), message); - - out.write(header); - out.write(message); - - LOGGER.log(Level.FINER, "[blocknet-serializer] Serialized " + name + " message. Bytes: " + new String(Hex.encode(header)) + new String(Hex.encode(message))); - } - - @Override - public void serialize(Message message, OutputStream out) throws IOException { - if (message instanceof XRouterMessage) { - params.getXRouterMessageSerializer(parseRetain).serialize(message, out); - } else { - String name = messageNames.get(message.getClass()); - if (name == null) { - LOGGER.log(Level.FINER, "[blocknet-serializer] ERROR: BlocknetSerializer cannot serialize " + message.getClass().getSimpleName() + " (yet)!"); - return; - } - serialize(name, message.bitcoinSerialize(), out); - } - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetTestnet5NetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetTestnet5NetworkParameters.java deleted file mode 100644 index 340a2fc..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetTestnet5NetworkParameters.java +++ /dev/null @@ -1,162 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.net.xrouter.XRouterMessageSerializer; -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.utils.MonetaryFormat; - -import java.math.BigInteger; - -public class BlocknetTestnet5NetworkParameters extends BlocknetParameters { - - public BlocknetTestnet5NetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "test"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException { - - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public Sha256Hash getGenesisBlockHash() { - return Sha256Hash.wrap("0fd62ae4f74c7ee0c11ef60fc5a2e69a5c02eaee2e77b21c3db70934b5a5c8b9"); - } - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(43199500).times(Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Coin.valueOf(5500); - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "tBLOCK"); - } - - @Override - public String getUriScheme() { - return "blockdx:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BlocknetSerializer getSerializer(boolean parseRetain) { - return new BlocknetSerializer(this, parseRetain); - } - - public XRouterMessageSerializer getXRouterMessageSerializer(boolean parseRetain) { - return new XRouterMessageSerializer(parseRetain, this); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70712; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210000; - } - - @Override - public byte[] getAlertSigningKey() { - return Hex.decode("000010e83b2703ccf322f7dbd62dd5855ac7c10bd055814ce121ba32607d573b8810c02c0582aed05b4deb9c4b77b26d92428c61256cd42774babea0a073b2ed0c9"); - } - - @Override - public int getMajorityEnforceBlockUpgrade() { - return 51; - } - - @Override - public int getMajorityRejectBlockOutdated() { - return 75; - } - - @Override - public int getMajorityWindow() { - return 100; - } - - @Override - public int getPort() { - return 41474; - } - - @Override - public long getPacketMagic() { - return 0x457665BAL; - } - - @Override - public int getInterval() { - return 1; - } - - @Override - public int getTargetTimespan() { - return 60; - } - - @Override - public int getAddressHeader() { - return 139; - } - - @Override - public int getP2SHHeader() { - return 19; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 239; - } - - @Override - public int getBip32HeaderPub() { - return 0x3A8061A0; - } - - @Override - public int getBip32HeaderPriv() { - return 0x3A805837; - } - - @Override - public String[] getDnsSeeds() { - return new String[] { - "104.238.198.122", - }; - } - - @Override - public BigInteger getMaxTarget() { - return Utils.decodeCompactBits(0x203FFFFF); - } - - @Override - public String getId() { - return "tBLOCK"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetUtils.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetUtils.java deleted file mode 100644 index 7ddd050..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetUtils.java +++ /dev/null @@ -1,54 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet; - -import org.bitcoinj.core.Sha256Hash; -import org.bitcoinj.core.Utils; - -import java.nio.ByteBuffer; -import java.util.Arrays; - -public class BlocknetUtils { - - public static void seekPastMagicBytes(ByteBuffer in, BlocknetParameters params) { - int magicCursor = 3; - while (true) { - byte b = in.get(); - - byte expectedByte = (byte)(0xFF & params.getPacketMagic() >>> (magicCursor * 8)); - if (b == expectedByte) { - magicCursor--; - if (magicCursor < 0) { - return; - } - } else { - magicCursor = 3; - } - } - } - - public static boolean verifyChecksum(BlocknetPacketHeader header, byte[] payloadBytes) { - byte[] hash = Sha256Hash.hashTwice(payloadBytes); - byte[] headerChecksum = new byte[4]; - byte[] checksum = new byte[4]; - System.arraycopy(header.getChecksum(), 0, headerChecksum, 0, 4); - System.arraycopy(hash, 0, checksum, 0, 4); - - return Arrays.equals(headerChecksum, checksum); - } - - public static byte[] getHeader(String name, int magic, byte[] payloadBytes) { - byte[] header = new byte[BlocknetPacketHeader.HEADER_LENGTH + 4]; - - Utils.uint32ToByteArrayBE(magic, header, 0); - - for (int i = 0; i < name.length() && i < 12; i++) { - header[4 + i] = (byte) (name.codePointAt(i) & 0xFF); - } - - Utils.uint32ToByteArrayLE(payloadBytes.length, header, 4 + 12); - byte[] hash = Sha256Hash.hashTwice(payloadBytes); - System.arraycopy(hash, 0, header, 4 + 12 + 4, 4); - - return header; - } - -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetOnBlocksDownloadedEventListener.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetOnBlocksDownloadedEventListener.java deleted file mode 100644 index 1b4301d..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetOnBlocksDownloadedEventListener.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet.listeners; - -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import org.bitcoinj.core.Block; -import org.bitcoinj.core.FilteredBlock; - -public interface BlocknetOnBlocksDownloadedEventListener { - - void onBlocksDownloaded(BlocknetPeer peer, Block block, FilteredBlock filteredBlock, int blocksLeft); - -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetOnXRouterMessageReceivedListener.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetOnXRouterMessageReceivedListener.java deleted file mode 100644 index 990f011..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetOnXRouterMessageReceivedListener.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet.listeners; - -import io.cloudchains.app.net.xrouter.XRouterMessage; - -public interface BlocknetOnXRouterMessageReceivedListener { - - void onXRouterMessageReceived(XRouterMessage message, XRouterMessage original); - -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPeerConnectedEventListener.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPeerConnectedEventListener.java deleted file mode 100644 index 8e75453..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPeerConnectedEventListener.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet.listeners; - -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; - -public interface BlocknetPeerConnectedEventListener { - - void onPeerConnected(BlocknetPeer peer, int peerCount); -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPeerDisconnectedEventListener.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPeerDisconnectedEventListener.java deleted file mode 100644 index cb98bc6..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPeerDisconnectedEventListener.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet.listeners; - -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; - -public interface BlocknetPeerDisconnectedEventListener { - - void onPeerDisconnected(BlocknetPeer peer, int peerCount); - -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPreMessageReceivedEventListener.java b/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPreMessageReceivedEventListener.java deleted file mode 100644 index 0647fcd..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/listeners/BlocknetPreMessageReceivedEventListener.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.cloudchains.app.net.protocols.blocknet.listeners; - -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import org.bitcoinj.core.Message; - -public interface BlocknetPreMessageReceivedEventListener { - - Message onPreMessageReceived(BlocknetPeer peer, Message message); - -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/dashcoin/DashcoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/dashcoin/DashcoinNetworkParameters.java deleted file mode 100644 index a54f828..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/dashcoin/DashcoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.dashcoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class DashcoinNetworkParameters extends NetworkParameters { - - public DashcoinNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(22000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Coin.valueOf(5460); - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "DASH"); - } - - @Override - public String getUriScheme() { - return "dashcoin:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70210; - } - - @Override - public int getAddressHeader() { - return 76; - } - - @Override - public int getP2SHHeader() { - return 16; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 204; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return 57; - } - - @Override - public String getId() { - return "DASH"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/digibyte/DigibyteNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/digibyte/DigibyteNetworkParameters.java deleted file mode 100644 index db571ed..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/digibyte/DigibyteNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.digibyte; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class DigibyteNetworkParameters extends NetworkParameters { - - public DigibyteNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(2000000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Coin.valueOf(1000); - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "DGB"); - } - - @Override - public String getUriScheme() { - return "digibyte:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70002; - } - - @Override - public int getAddressHeader() { - return 30; - } - - @Override - public int getP2SHHeader() { - return 5; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 128; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 100000; - } - - @Override - public int getInterval() { - return 108; - } - - @Override - public String getId() { - return "DGB"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/dogecoin/DogecoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/dogecoin/DogecoinNetworkParameters.java deleted file mode 100644 index 7390103..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/dogecoin/DogecoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.dogecoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class DogecoinNetworkParameters extends NetworkParameters { - - public DogecoinNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(2000000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "DOGE"); - } - - @Override - public String getUriScheme() { - return "dogecoin:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70004; - } - - @Override - public int getAddressHeader() { - return 30; - } - - @Override - public int getP2SHHeader() { - return 22; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 158; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x02fac398; - } - - @Override - public int getBip32HeaderPub() { - return 0x02facafd; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 100000; - } - - @Override - public int getInterval() { - return 108; - } - - @Override - public String getId() { - return "DOGE"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/litecoin/LitecoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/litecoin/LitecoinNetworkParameters.java deleted file mode 100644 index d2e0d7b..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/litecoin/LitecoinNetworkParameters.java +++ /dev/null @@ -1,105 +0,0 @@ -package io.cloudchains.app.net.protocols.litecoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class LitecoinNetworkParameters extends NetworkParameters { - - public LitecoinNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(84000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Coin.valueOf(100000); - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "LTC"); - } - - @Override - public String getUriScheme() { - return "litecoin:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70015; - } - - @Override - public int getAddressHeader() { - return 48; - } - - @Override - public int getP2SHHeader() { - return 50; - } - - public int getP2SHLegacyHeader() { - return 5; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 176; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader(), getP2SHLegacyHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488B21E; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488ADE4; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 840000; - } - - @Override - public int getInterval() { - return 2016; - } - - @Override - public String getId() { - return "LTC"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/phorecoin/PhorecoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/phorecoin/PhorecoinNetworkParameters.java deleted file mode 100644 index 607a935..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/phorecoin/PhorecoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.phorecoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class PhorecoinNetworkParameters extends NetworkParameters { - - public PhorecoinNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(100000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "PHR"); - } - - @Override - public String getUriScheme() { - return "phore:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70007; - } - - @Override - public int getAddressHeader() { - return 55; - } - - @Override - public int getP2SHHeader() { - return 13; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 212; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0221312B; - } - - @Override - public int getBip32HeaderPub() { - return 0x022D2533; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return 60; - } - - @Override - public String getId() { - return "PHR"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/pivx/PivxNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/pivx/PivxNetworkParameters.java deleted file mode 100644 index 3fb808b..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/pivx/PivxNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.pivx; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class PivxNetworkParameters extends NetworkParameters { - - public PivxNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(100000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "PIVX"); - } - - @Override - public String getUriScheme() { - return "pivx:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70007; - } - - @Override - public int getAddressHeader() { - return 30; - } - - @Override - public int getP2SHHeader() { - return 13; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 212; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0221312B; - } - - @Override - public int getBip32HeaderPub() { - return 0x022D2533; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return 60; - } - - @Override - public String getId() { - return "PIVX"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/poliscoin/PoliscoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/poliscoin/PoliscoinNetworkParameters.java deleted file mode 100644 index a4b976d..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/poliscoin/PoliscoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.poliscoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class PoliscoinNetworkParameters extends NetworkParameters { - - public PoliscoinNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(25000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "POLIS"); - } - - @Override - public String getUriScheme() { - return "polis:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70219; - } - - @Override - public int getAddressHeader() { - return 55; - } - - @Override - public int getP2SHHeader() { - return 56; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 60; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x03E25945; - } - - @Override - public int getBip32HeaderPub() { - return 0x03E25D7E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return 120; - } - - @Override - public String getId() { - return "DASH"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/ravencoin/RavencoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/ravencoin/RavencoinNetworkParameters.java deleted file mode 100644 index 5be75bf..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/ravencoin/RavencoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.ravencoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class RavencoinNetworkParameters extends NetworkParameters { - - public RavencoinNetworkParameters() { - super(); - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(100000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Transaction.MIN_NONDUST_OUTPUT; - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "RVN"); - } - - @Override - public String getUriScheme() { - return "ravencoin:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70026; - } - - @Override - public int getAddressHeader() { - return 60; - } - - @Override - public int getP2SHHeader() { - return 122; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 128; - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 210240; - } - - @Override - public int getInterval() { - return 60; - } - - @Override - public String getId() { - return "RVN"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/syscoin/SyscoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/syscoin/SyscoinNetworkParameters.java deleted file mode 100644 index d0a284b..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/syscoin/SyscoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.syscoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class SyscoinNetworkParameters extends NetworkParameters { - - public SyscoinNetworkParameters() { - super(); - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(888000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Coin.valueOf(5500); - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "SYS"); - } - - @Override - public String getUriScheme() { - return "syscoin:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70227; - } - - @Override - public int getAddressHeader() { - return 63; - } - - @Override - public int getP2SHHeader() { - return 5; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 128; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 525600; - } - - @Override - public int getInterval() { - return 2016; - } - - @Override - public String getId() { - return "SYS"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/protocols/trezarcoin/TrezarcoinNetworkParameters.java b/src/main/java/io/cloudchains/app/net/protocols/trezarcoin/TrezarcoinNetworkParameters.java deleted file mode 100644 index df89e54..0000000 --- a/src/main/java/io/cloudchains/app/net/protocols/trezarcoin/TrezarcoinNetworkParameters.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.cloudchains.app.net.protocols.trezarcoin; - -import org.bitcoinj.core.*; -import org.bitcoinj.store.BlockStore; -import org.bitcoinj.store.BlockStoreException; -import org.bitcoinj.utils.MonetaryFormat; - -public class TrezarcoinNetworkParameters extends NetworkParameters { - - public TrezarcoinNetworkParameters() { - super(); - } - - @Override - public int[] getAcceptableAddressCodes() { - return new int[] {getAddressHeader(), getP2SHHeader()}; - } - - @Override - public String getPaymentProtocolId() { - return "main"; - } - - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} - - @Override - public Coin getMaxMoney() { - return Coin.valueOf(888000000 * Coin.COIN.value); - } - - @Override - public Coin getMinNonDustOutput() { - return Coin.valueOf(5500); - } - - @Override - public MonetaryFormat getMonetaryFormat() { - return new MonetaryFormat().code(0, "TZC"); - } - - @Override - public String getUriScheme() { - return "trezarcoin:"; - } - - @Override - public boolean hasMaxMoney() { - return true; - } - - @Override - public BitcoinSerializer getSerializer(boolean parseRetain) { - return new BitcoinSerializer(this, parseRetain); - } - - @Override - public int getProtocolVersionNum(ProtocolVersion version) { - return 70000; - } - - @Override - public int getAddressHeader() { - return 66; - } - - @Override - public int getP2SHHeader() { - return 8; - } - - @Override - public int getDumpedPrivateKeyHeader() { - return 194; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } - - @Override - public int getSubsidyDecreaseBlockCount() { - return 1600000; - } - - @Override - public int getInterval() { - return 600; - } - - @Override - public String getId() { - return "TZC"; - } -} diff --git a/src/main/java/io/cloudchains/app/net/substitutions/ApacheSubstitutions.java b/src/main/java/io/cloudchains/app/net/substitutions/ApacheSubstitutions.java deleted file mode 100644 index f56a314..0000000 --- a/src/main/java/io/cloudchains/app/net/substitutions/ApacheSubstitutions.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.cloudchains.app.net.substitutions; - -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.apache.commons.logging.impl.LogFactoryImpl; -import org.apache.commons.logging.impl.SimpleLog; - - -@SuppressWarnings("unused") -@TargetClass(LogFactory.class) -final class LogFactorySubstituted { - @Substitute - protected static LogFactory newFactory(final String factoryClass, - final ClassLoader classLoader, - final ClassLoader contextClassLoader) { - return new LogFactoryImpl(); - } -} - -@SuppressWarnings("unused") -@TargetClass(LogFactoryImpl.class) -final class LogFactoryImplSubstituted { - @Substitute - private Log discoverLogImplementation(String logCategory) { - return new SimpleLog(logCategory); - } -} - -public class ApacheSubstitutions { -} diff --git a/src/main/java/io/cloudchains/app/net/substitutions/NettySubstitutions.java b/src/main/java/io/cloudchains/app/net/substitutions/NettySubstitutions.java deleted file mode 100644 index ffb1480..0000000 --- a/src/main/java/io/cloudchains/app/net/substitutions/NettySubstitutions.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.cloudchains.app.net.substitutions; - -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; -import io.netty.util.internal.logging.InternalLoggerFactory; -import io.netty.util.internal.logging.JdkLoggerFactory; - - -@TargetClass(io.netty.util.internal.logging.InternalLoggerFactory.class) -final class TargetInternalLoggerFactory { - @Substitute - private static InternalLoggerFactory newDefaultFactory(String name) { - return JdkLoggerFactory.INSTANCE; - } -} - -public class NettySubstitutions { -} \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/net/xrouter/XRouterCommandUtils.java b/src/main/java/io/cloudchains/app/net/xrouter/XRouterCommandUtils.java deleted file mode 100644 index 9c840a1..0000000 --- a/src/main/java/io/cloudchains/app/net/xrouter/XRouterCommandUtils.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.cloudchains.app.net.xrouter; - -import com.google.common.collect.HashBiMap; - -public class XRouterCommandUtils { - - private static final HashBiMap commands; - - static { - commands = HashBiMap.create(19); - - commands.put("xrInvalid", 0); - commands.put("xrReply", 1); - commands.put("xrGetReply", 2); - commands.put("xrGetConfig", 3); - commands.put("xrConfigReply", 4); - commands.put("xrGetBlockCount", 20); - commands.put("xrGetBlockHash", 21); - commands.put("xrGetBlock", 22); - commands.put("xrGetTransaction", 23); - commands.put("xrSendTransaction", 24); - commands.put("xrGetTxBloomFilter", 40); - commands.put("xrGenerateBloomFilter", 41); - commands.put("xrGetBlocks", 50); - commands.put("xrGetTransactions", 51); - commands.put("xrGetBlockAtTime", 52); - commands.put("xrDecodeRawTransaction", 53); - commands.put("xrGetBalance", 60); - commands.put("xrGetBalanceUpdate", 61); - commands.put("xrService", 1000); - } - - public static int commandStringToInt(String command) { - Integer commandId = commands.get(command); - return commandId == null ? 0 : commandId; - } - - public static String commandIdToString(int commandId) { - String command = commands.inverse().get(commandId); - return command == null ? "xrInvalid" : command; - } - -} diff --git a/src/main/java/io/cloudchains/app/net/xrouter/XRouterFeeUtils.java b/src/main/java/io/cloudchains/app/net/xrouter/XRouterFeeUtils.java deleted file mode 100644 index 427a131..0000000 --- a/src/main/java/io/cloudchains/app/net/xrouter/XRouterFeeUtils.java +++ /dev/null @@ -1,111 +0,0 @@ -package io.cloudchains.app.net.xrouter; - -import com.google.common.base.Preconditions; -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import io.cloudchains.app.util.XRouterConfiguration; -import io.cloudchains.app.wallet.WalletHelper; -import org.bitcoinj.core.*; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class XRouterFeeUtils { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - public static String getXRouterFeeTx(BlocknetPeer blocknetPeer, String xRouterCommand) { - CoinInstance blocknetCoin = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()); - WalletHelper blocknetWalletHelper = blocknetCoin.getWalletHelper(); - NetworkParameters params = blocknetCoin.getNetworkParameters(); - XRouterConfiguration xRouterConfig = blocknetPeer.getxRouterConfiguration(); - - Preconditions.checkNotNull(xRouterConfig, "XRouter config was not received yet!"); - - HashMap feeMap = xRouterConfig.getFeeMap(); - - if (!feeMap.containsKey(xRouterCommand)) { - LOGGER.log(Level.FINER, "[xrouter-fee-utils] WARNING: Invalid/unknown XRouter command supplied to getXRouterFeeTx()! Assuming this command is free."); - LOGGER.log(Level.FINER, "[xrouter-fee-utils] Command: " + xRouterCommand); - - return "nohash;nofee"; - } - - double fee = feeMap.get(xRouterCommand); - - Coin xRouterFeeAmt = Coin.valueOf((long) Math.floor(fee * Coin.COIN.value)); - if (xRouterFeeAmt.value == 0) { - LOGGER.log(Level.FINER, "[xrouter-fee-utils] DEBUG: This command is free."); - return "nohash;nofee"; - } - - double totalSpending = fee + blocknetCoin.getConfigHelper().getFee(); - double totalAvailable = blocknetWalletHelper.getSpendBalance(totalSpending); - double changeAmt = ((totalAvailable - blocknetCoin.getConfigHelper().getFee()) - fee); - - Address xRouterPaymentAddress = Address.fromBase58(params, xRouterConfig.getFeeAddress()); - Coin blocknetNetworkFeeAmt = Coin.valueOf((long) Math.floor(blocknetCoin.getConfigHelper().getFee() * Coin.COIN.value)); - Coin xRouterChangeAmt = Coin.valueOf((long) Math.floor(totalAvailable * Coin.COIN.value)).minus(blocknetNetworkFeeAmt).minus(xRouterFeeAmt); - - TransactionOutput feeOutput = new TransactionOutput(params, null, xRouterFeeAmt, xRouterPaymentAddress); - - ArrayList outputs = new ArrayList<>(); - outputs.add(feeOutput); - - if (changeAmt > 0.06) { - double halvedAmt = changeAmt / 3; - Coin halvedChangeAmt = Coin.valueOf((long) Math.floor(halvedAmt * Coin.COIN.value)); - TransactionOutput halvedChangeOutput = new TransactionOutput(params, null, halvedChangeAmt, blocknetWalletHelper.getChangeAddress()); - - for (int i = 0; i < 3; i++) { - outputs.add(halvedChangeOutput); - } - } else { - TransactionOutput changeOutput = new TransactionOutput(params, null, xRouterChangeAmt, blocknetWalletHelper.getChangeAddress()); - outputs.add(changeOutput); - } - - Transaction xRouterFeeTx = blocknetWalletHelper.createRawTransactionWithAllUTXOs(outputs, totalAvailable); - - String feetx = new String(Hex.encode(xRouterFeeTx.bitcoinSerialize())); - LOGGER.log(Level.FINER, "[xrouter-fee-utils] XRouter fee transaction string representation:"); - LOGGER.log(Level.FINER, xRouterFeeTx.toString()); - LOGGER.log(Level.FINER, "[xrouter-fee-utils] DEBUG: Feetx: " + feetx); - return feetx; - } - - public static TransactionOutput createXrSendTransactionFeeOutput(BlocknetPeer blocknetPeer) { - CoinInstance blocknetCoin = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()); - XRouterConfiguration xRouterConfig = blocknetPeer.getxRouterConfiguration(); - HashMap feeMap = xRouterConfig.getFeeMap(); - Preconditions.checkState(feeMap.containsKey("xrSendTransaction"), "Fee map has no fee for xrSendTransaction"); - - double fee = feeMap.get("xrSendTransaction"); - String feeAddress = xRouterConfig.getFeeAddress(); - - Coin feeAmount = Coin.valueOf((long) Math.floor(fee * Coin.COIN.value)); - - return new TransactionOutput(blocknetCoin.getNetworkParameters(), null, feeAmount, Address.fromBase58(blocknetCoin.getNetworkParameters(), feeAddress)); - } - - public static String coveredXrFee(BlocknetPeer blocknetPeer, Transaction transaction) { - CoinInstance blocknetCoin = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()); - WalletHelper.setAsSpent(blocknetCoin.getTicker(), transaction, true); - - String xrFee = getXRouterFeeTx(blocknetPeer, "xrSendTransaction"); - - if (xrFee.equals("nohash;nofee")) - return xrFee; - - Transaction tx = new Transaction(blocknetCoin.getNetworkParameters(), Hex.decode(xrFee)); - - if (tx.getInputs().size() == 0) - return null; - - return xrFee; - } -} diff --git a/src/main/java/io/cloudchains/app/net/xrouter/XRouterInitialMessagesSentListener.java b/src/main/java/io/cloudchains/app/net/xrouter/XRouterInitialMessagesSentListener.java deleted file mode 100644 index c07258a..0000000 --- a/src/main/java/io/cloudchains/app/net/xrouter/XRouterInitialMessagesSentListener.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.cloudchains.app.net.xrouter; - -import io.cloudchains.app.net.CoinInstance; - -public interface XRouterInitialMessagesSentListener { - - void initialMessagesSent(CoinInstance instance); - -} diff --git a/src/main/java/io/cloudchains/app/net/xrouter/XRouterMessage.java b/src/main/java/io/cloudchains/app/net/xrouter/XRouterMessage.java deleted file mode 100644 index 1532524..0000000 --- a/src/main/java/io/cloudchains/app/net/xrouter/XRouterMessage.java +++ /dev/null @@ -1,444 +0,0 @@ -package io.cloudchains.app.net.xrouter; - -import com.google.common.base.Preconditions; -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.net.protocols.blocknet.BlocknetParameters; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import io.cloudchains.app.util.XRouterConfiguration; -import org.bitcoinj.core.Message; -import org.bitcoinj.core.ProtocolException; -import org.bitcoinj.core.Utils; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class XRouterMessage extends Message { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private BlocknetPeer blocknetPeer; - - private XRouterPacketHeader xRouterHeader; - private byte[] data; - - private HashMap parsedData; - private BlocknetParameters params; - - public XRouterMessage(BlocknetParameters params, byte[] data) { - this.data = data; - - this.params = params; - this.parsedData = new HashMap<>(); - parseHeader(); - parse(); - } - - XRouterMessage(BlocknetPeer blocknetPeer, BlocknetParameters params, XRouterPacketHeader xRouterHeader, HashMap parsedData) { - this.blocknetPeer = blocknetPeer; - - this.xRouterHeader = xRouterHeader; - this.parsedData = parsedData; - - this.params = params; - this.data = bitcoinSerialize(); - } - - private void writeCurrency(HashMap body, OutputStream out) throws IOException { - out.write(((String) body.get("currency")).getBytes()); - out.write(0x00); - } - - private void writeAccountAndNumber(String account, String number, OutputStream out) throws IOException { - out.write(account.getBytes()); - out.write(0x00); - out.write(number.getBytes()); - out.write(0x00); - } - - /** - * Write Payment TX to unserialized XRouter Packet - * @param body HashMap The parsed XRouter packet body - * @param out OutputStream The output stream to which to write the serialized transaction info - * @throws IOException If writing fails or another error occurs - */ - private void writePaymentTx(HashMap body, OutputStream out) throws IOException { - if (!body.containsKey("paymentTx")) - return; - - out.write(((String) body.get("paymentTx")).getBytes()); - out.write(0x00); - } - - public XRouterPacketHeader getXRouterHeader() { - return xRouterHeader; - } - - public HashMap getParsedData() { - return parsedData; - } - - @Override - public byte[] bitcoinSerialize() { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - - try { - bitcoinSerializeToStream(byteArrayOutputStream); - } catch (Exception e) { - LOGGER.log(Level.FINER, "Error while serializing XRouter packet! Invalid packet structure?"); - e.printStackTrace(); - return null; - } - - return byteArrayOutputStream.toByteArray(); - } - - @Override - protected void bitcoinSerializeToStream(OutputStream stream) throws IOException { - if (xRouterHeader.getExtSize() < 253) { - stream.write(xRouterHeader.getCompactSizeBytes()); - } else if (xRouterHeader.getExtSize() <= 65535) { - stream.write((byte) 253); - stream.write(xRouterHeader.getCompactSizeBytes()); - } else { - stream.write((byte) 254); - stream.write(xRouterHeader.getCompactSizeBytes()); - } - - Utils.uint32ToByteStreamLE(xRouterHeader.getVersion(), stream); - Utils.uint32ToByteStreamLE(xRouterHeader.getCommand(), stream); - Utils.uint32ToByteStreamLE(xRouterHeader.getTimestamp(), stream); - Utils.uint32ToByteStreamLE(xRouterHeader.getSize(), stream); - stream.write(new byte[8]); - stream.write(xRouterHeader.getUUID().getBytes()); - stream.write(xRouterHeader.getPubkey()); - stream.write(xRouterHeader.getSignature()); - - switch (XRouterCommandUtils.commandIdToString(xRouterHeader.getCommand())) { - case "xrReply": - case "xrConfigReply": { - stream.write(((String) parsedData.get("reply")).getBytes()); - stream.write(0x00); - break; - } - case "xrGetReply": { - LOGGER.log(Level.FINER, "[xrouter-message] DEBUG: Fetching reply for packet " + xRouterHeader.getUUID()); - break; - } - case "xrGetConfig": { - stream.write(((String) parsedData.get("addr")).getBytes()); - stream.write(0x00); - break; - } - case "xrGetBlockCount": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - Utils.uint32ToByteStreamLE(0, stream); //param count - break; - } - case "xrGetBlockHash": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - Utils.uint32ToByteStreamLE(1, stream); //param count - stream.write(((String) parsedData.get("blockId")).getBytes()); - stream.write(0x00); - break; - } - case "xrGetBlock": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - Utils.uint32ToByteStreamLE(1, stream); //param count - stream.write(((String) parsedData.get("blockHash")).getBytes()); - stream.write(0x00); - break; - } - case "xrGetTransaction": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - Utils.uint32ToByteStreamLE(1, stream); //param count - stream.write(((String) parsedData.get("txid")).getBytes()); - stream.write(0x00); - break; - } - case "xrSendTransaction": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - Utils.uint32ToByteStreamLE(1, stream); //param count - stream.write(((String) parsedData.get("transaction")).getBytes()); - stream.write(0x00); - break; - } - case "xrGetTxBloomFilter": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - stream.write(((String) parsedData.get("number_s")).getBytes()); - stream.write(0x00); - break; - } - case "xrGenerateBloomFilter": { - LOGGER.log(Level.FINER, "[xrouter-message] ERROR: Attempted to serialize unsupported command 41."); - break; - } - case "xrGetBlocks": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - writeAccountAndNumber((String) parsedData.get("account"), (String) parsedData.get("number"), stream); - break; - } - case "xrGetTransactions": - case "xrGetBalanceUpdate": { - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - writeAccountAndNumber((String) parsedData.get("account"), (String) parsedData.get("number_s"), stream); - break; - } - case "xrGetBlockAtTime": { - LOGGER.log(Level.FINER, "[xrouter-message] ERROR: Attempted to serialize unsupported command 52."); - break; - } - case "xrGetBalance": { //OBSOLETE, only implemented for backwards compatibility - writeCurrency(parsedData, stream); - writePaymentTx(parsedData, stream); - stream.write(((String) parsedData.get("account")).getBytes()); - stream.write(0x00); - break; - } - case "xrService": { - String command = (String) parsedData.get("command"); - LOGGER.log(Level.FINER, "[xrService] Command: " + command); - - XRouterConfiguration.XRouterPluginConfiguration pluginConfig = blocknetPeer.getPluginConfig(command); - - if (pluginConfig == null) { - LOGGER.log(Level.FINER, "[xrService] ERROR: Unsupported server xrs plugin: " + command); - LOGGER.log(Level.FINER, "[xrService] ERROR: Aborting transmission."); - throw new IllegalArgumentException("Unsupported server xrs plugin: " + command); - } - - ArrayList pluginParamTypes = pluginConfig.getParamTypes(); - - stream.write(command.getBytes()); - stream.write(0x00); - - writePaymentTx(parsedData, stream); - - ArrayList params = (ArrayList) parsedData.get("params"); - - Utils.uint32ToByteStreamLE(params.size(), stream); //param count - - for (int i = 0; i < params.size(); i++) { - Class paramClass = pluginParamTypes.get(i); - Object param = params.get(i); - String classStr = XRouterConfiguration.getStringByClass(paramClass); - - if (!(param instanceof String && ((String) param).equalsIgnoreCase("true") || ((String) param).equalsIgnoreCase("false"))) - Preconditions.checkState(paramClass.isInstance(param), "Supplied parameter at index " + i + " is not of type '" + classStr + "'. Aborting transmission."); - - LOGGER.log(Level.FINER, "[xrService] DEBUG: Parameter " + i + " is of type " + classStr); - - switch (classStr) { - case "string": { - String typedParam = (String) param; - stream.write(typedParam.getBytes()); - stream.write(0x00); - break; - } - case "bool": { - int typedParam; - - if (((String) param).equalsIgnoreCase("true")) - typedParam = 1; - else - typedParam = 0; - - Utils.uint32ToByteStreamLE(typedParam, stream); - break; - } - case "int": { - Integer typedParam = (Integer) param; - Utils.uint32ToByteStreamLE(typedParam, stream); - break; - } - default: { - LOGGER.log(Level.FINER, "[xrService] ERROR: Encountered unhandled parameter of type " + classStr + ". Aborting transmission."); - throw new IllegalStateException("Bad parameter type at index " + i + ": " + classStr); - } - } - } - break; - } - default: { //xrInvalid - break; - } - } - } - - private String readStringNT(ByteBuffer in) { - StringBuilder stringBuilder = new StringBuilder(); - - byte buf; - - while ((buf = in.get()) != 0x00) { - stringBuilder.append((char) buf); - } - - return stringBuilder.toString(); - } - - private void readCurrency(ByteBuffer buf) { - String currency = readStringNT(buf); - parsedData.put("currency", currency); - } - - private void readPaymentTx(ByteBuffer buf) { - String paymentTx = readStringNT(buf); - parsedData.put("paymentTx", paymentTx); - } - - private void readAccountAndNumber(ByteBuffer buf, String secondFieldName) { - String account = readStringNT(buf); - parsedData.put("account", account); - offset += account.length(); - - String number = readStringNT(buf); - parsedData.put(secondFieldName, number); - offset += number.length(); - } - - private void parseHeader() throws ProtocolException { - xRouterHeader = new XRouterPacketHeader(ByteBuffer.wrap(data)); - } - - @Override - protected void parse() throws ProtocolException { - parsedData.put("header", xRouterHeader); - - LOGGER.log(Level.FINER, "Received raw XRouter packet: " + new String(Hex.encode(data))); - ByteBuffer buf = ByteBuffer.wrap(data); - buf.position(xRouterHeader.getHeaderLength()); - - int command = xRouterHeader.getCommand(); - - switch (XRouterCommandUtils.commandIdToString(command)) { - case "xrReply": - case "xrConfigReply": { - String reply = readStringNT(buf); - parsedData.put("reply", reply); - LOGGER.log(Level.FINER, "[xrouter-message] Got reply: '" + reply + "' for packet with UUID '" + xRouterHeader.getUUID() + "'"); - break; - } - case "xrGetReply": { - LOGGER.log(Level.FINER, "[xrouter-message] WARNING: Server asked to fetch reply, but we aren't a server."); - break; - } - case "xrGetConfig": { - LOGGER.log(Level.FINER, "[xrouter-message] WARNING: Server asked us for config, but we aren't a servicenode."); - break; - } - case "xrGetBlockCount": { - readCurrency(buf); - readPaymentTx(buf); - break; - } - case "xrGetBlockHash": { - readCurrency(buf); - - String blockId = readStringNT(buf); - parsedData.put("blockId", blockId); - - readPaymentTx(buf); - break; - } - case "xrGetBlock": { - readCurrency(buf); - - String blockHash = readStringNT(buf); - parsedData.put("blockHash", blockHash); - - readPaymentTx(buf); - break; - } - case "xrGetTransaction": { - readCurrency(buf); - - String txid = readStringNT(buf); - parsedData.put("txid", txid); - - readPaymentTx(buf); - break; - } - case "xrGetBlocks": { - readCurrency(buf); - readPaymentTx(buf); - - readAccountAndNumber(buf, "number"); - break; - } - case "xrGetTransactions": - case "xrGetBalanceUpdate": { - readCurrency(buf); - readPaymentTx(buf); - - readAccountAndNumber(buf, "number_s"); - break; - } - case "xrGetBalance": { //OBSOLETE - readCurrency(buf); - - String account = readStringNT(buf); - parsedData.put("account", account); - - readPaymentTx(buf); - break; - } - case "xrGetTxFilter": { - readCurrency(buf); - readPaymentTx(buf); - - String number_s = readStringNT(buf); - parsedData.put("number_s", number_s); - break; - } - case "xrSendTransaction": { - readCurrency(buf); - readPaymentTx(buf); - - String transaction = readStringNT(buf); - parsedData.put("transaction", transaction); - break; - } - case "xrGetBlockAtTime": { - readCurrency(buf); - - String timestamp = readStringNT(buf); - parsedData.put("timestamp", timestamp); - - readPaymentTx(buf); - break; - } - case "xrService": { - - String paymentTx = readStringNT(buf); - parsedData.put("command", paymentTx); - - ArrayList params = new ArrayList<>(); - while (buf.position() < buf.capacity()) { - String thisParam = readStringNT(buf); - params.add(thisParam); - } - - parsedData.put("params", params); - break; - } - default: { //xbcInvalid - break; - } - } - } -} diff --git a/src/main/java/io/cloudchains/app/net/xrouter/XRouterMessageSerializer.java b/src/main/java/io/cloudchains/app/net/xrouter/XRouterMessageSerializer.java deleted file mode 100644 index 716bd0c..0000000 --- a/src/main/java/io/cloudchains/app/net/xrouter/XRouterMessageSerializer.java +++ /dev/null @@ -1,134 +0,0 @@ -package io.cloudchains.app.net.xrouter; - -import com.google.common.base.Preconditions; -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPacketHeader; -import io.cloudchains.app.net.protocols.blocknet.BlocknetParameters; -import io.cloudchains.app.net.protocols.blocknet.BlocknetUtils; -import org.bitcoinj.core.*; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.BufferUnderflowException; -import java.nio.ByteBuffer; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class XRouterMessageSerializer extends MessageSerializer { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private boolean parseRetain; - private BlocknetParameters params; - - public XRouterMessageSerializer(boolean parseRetain, BlocknetParameters params) { - this.parseRetain = parseRetain; - this.params = params; - } - - @Override - public XRouterMessage deserialize(ByteBuffer in) throws ProtocolException, UnsupportedOperationException { - seekPastMagicBytes(in); - BlocknetPacketHeader header = deserializeHeader(in); - - return deserializePayload(header, in); - } - - @Override - public BlocknetPacketHeader deserializeHeader(ByteBuffer in) throws ProtocolException, UnsupportedOperationException { - return new BlocknetPacketHeader(in); - } - - @Override - public XRouterMessage deserializePayload(BitcoinSerializer.BitcoinPacketHeader header, ByteBuffer in) throws ProtocolException, BufferUnderflowException, UnsupportedOperationException { - BlocknetPacketHeader blocknetPacketHeader = (BlocknetPacketHeader) header; - - byte[] payloadBytes = new byte[blocknetPacketHeader.getLength()]; - in.get(payloadBytes, 0, payloadBytes.length); - - if (!BlocknetUtils.verifyChecksum(blocknetPacketHeader, payloadBytes)) { - throw new ProtocolException("XRouter packet's checksum failed to verify."); - } - - int dataLength = in.capacity() - in.position(); - - byte[] data = new byte[dataLength]; - in.get(data, 0, dataLength); - - return new XRouterMessage(params, data); - } - - @Override - public boolean isParseRetainMode() { - return parseRetain; - } - - @Override - public AddressMessage makeAddressMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer does not support address message construction."); - } - - @Override - public Message makeAlertMessage(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer does not support alert message construction."); - } - - @Override - public Block makeBlock(byte[] payloadBytes, int offset, int length) throws ProtocolException, UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer does not support block construction."); - } - - @Override - public Message makeBloomFilter(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer does not support bloom filter construction."); - } - - @Override - public FilteredBlock makeFilteredBlock(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer does not support block construction."); - } - - @Override - public InventoryMessage makeInventoryMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer does not support inventory message construction."); - } - - @Override - public Transaction makeTransaction(byte[] payloadBytes, int offset, int length, byte[] hash) throws ProtocolException, UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer does not support transaction construction"); - } - - @Override - public void serialize(String name, byte[] message, OutputStream out) throws UnsupportedOperationException { - throw new UnsupportedOperationException("This serializer currently does not support name/message serialization."); - } - - private void serialize(byte[] data, OutputStream out) throws IOException { - byte[] header = BlocknetUtils.getHeader("xrouter", (int) params.getPacketMagic(), data); - - out.write(header); - out.write(data); - - LOGGER.log(Level.FINER, "[blocknet-serializer] Serialized xrouter message. Bytes: " + new String(Hex.encode(header)) + new String(Hex.encode(data))); - } - - /** - * Serialize XRouter packet header and body - * @param message XRouterMessage The XRouter message to serialize - * @param out OutputStream The output stream to which to write the serialized XRouter message bytes - * @throws IOException If writing fails or another I/O related error occurs - */ - @Override - public void serialize(Message message, OutputStream out) throws IOException { - Preconditions.checkArgument(message instanceof XRouterMessage, "This message is not an XRouter message."); - - XRouterMessage xRouterMessage = (XRouterMessage) message; - serialize(xRouterMessage.bitcoinSerialize(), out); - } - - @Override - public void seekPastMagicBytes(ByteBuffer in) throws BufferUnderflowException { - BlocknetUtils.seekPastMagicBytes(in, params); - } -} diff --git a/src/main/java/io/cloudchains/app/net/xrouter/XRouterPacketHeader.java b/src/main/java/io/cloudchains/app/net/xrouter/XRouterPacketHeader.java deleted file mode 100644 index f99cebd..0000000 --- a/src/main/java/io/cloudchains/app/net/xrouter/XRouterPacketHeader.java +++ /dev/null @@ -1,137 +0,0 @@ -package io.cloudchains.app.net.xrouter; - -import com.subgraph.orchid.encoders.Hex; -import org.bitcoinj.core.Utils; - -import java.nio.ByteBuffer; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class XRouterPacketHeader { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private byte[] compactSizeBytes; - private int version; - private int command; - private int timestamp; - private int size; - private String uuid; - private byte[] pubkey; - private byte[] signature; - - private int headerLength; - - public XRouterPacketHeader(ByteBuffer in) { - byte compactSize = in.get(); - byte[] rawHeader; - in.rewind(); - int cursor = 1; - - if (compactSize == (byte) 253) { - rawHeader = new byte[160]; - in.get(rawHeader, 0, rawHeader.length); - compactSizeBytes = new byte[2]; - } else if (compactSize == (byte) 254) { - rawHeader = new byte[162]; - in.get(rawHeader, 0, rawHeader.length); - compactSizeBytes = new byte[4]; - } else if (compactSize == (byte) 255) { - rawHeader = new byte[166]; - in.get(rawHeader, 0, rawHeader.length); - compactSizeBytes = new byte[8]; - } else { - rawHeader = new byte[158]; - in.get(rawHeader, 0, rawHeader.length); - compactSizeBytes = new byte[1]; - cursor = 0; - } - - System.arraycopy(rawHeader, cursor, compactSizeBytes, 0, compactSizeBytes.length); - cursor += compactSizeBytes.length; - - LOGGER.log(Level.FINER, "[xrouter] Retrieved compact size: " + new String(Hex.encode(new byte[]{compactSize}))); - LOGGER.log(Level.FINER, "[xrouter] Retrieved compact size bytes: " + new String(Hex.encode(compactSizeBytes))); - - version = (int) Utils.readUint32(rawHeader, cursor); - cursor += 4; - LOGGER.log(Level.FINER, "[xrouter] Retrieved version: " + version); - command = (int) Utils.readUint32(rawHeader, cursor); - cursor += 4; - LOGGER.log(Level.FINER, "[xrouter] Retrieved command: " + command); - timestamp = (int) Utils.readUint32(rawHeader, cursor); - cursor += 4; - LOGGER.log(Level.FINER, "[xrouter] Retrieved timestamp: " + timestamp); - size = (int) Utils.readUint32(rawHeader, cursor); - cursor += 4; - LOGGER.log(Level.FINER, "[xrouter] Retrieved size: " + size); - - //reserved header fields - //we don't use these fields, so we skip them - cursor += 8; - - byte[] uuidArr = new byte[36]; - System.arraycopy(rawHeader, cursor, uuidArr, 0, uuidArr.length); - cursor += 36; - - uuid = new String(uuidArr); - LOGGER.log(Level.FINER, "[xrouter] Retrieved UUID: " + uuid); - - byte[] pubkeyArr = new byte[33]; - System.arraycopy(rawHeader, cursor, pubkeyArr, 0, pubkeyArr.length); - cursor += 33; - LOGGER.log(Level.FINER, "[xrouter] Retrieved pubkey: " + new String(Hex.encode(pubkeyArr))); - pubkey = pubkeyArr; - - byte[] sigArr = new byte[64]; - System.arraycopy(rawHeader, cursor, sigArr, 0, sigArr.length); - cursor += 64; - LOGGER.log(Level.FINER, "[xrouter] Retrieved signature: " + new String(Hex.encode(sigArr))); - signature = sigArr; - - LOGGER.log(Level.FINER, "[xrouter] XRouter header read complete, at position: " + cursor); - headerLength = cursor; - //should have read 157 bytes at this point (excluding compact size) - } - - byte[] getCompactSizeBytes() { - return compactSizeBytes; - } - - int getVersion() { - return version; - } - - public int getCommand() { - return command; - } - - int getTimestamp() { - return timestamp; - } - - int getExtSize() { - return size + 157; - } - - int getSize() { - return size; - } - - public String getUUID() { - return uuid; - } - - byte[] getPubkey() { - return pubkey; - } - - byte[] getSignature() { - return signature; - } - - int getHeaderLength() { - return headerLength; - } -} diff --git a/src/main/java/io/cloudchains/app/net/xrouter/XRouterPacketManager.java b/src/main/java/io/cloudchains/app/net/xrouter/XRouterPacketManager.java deleted file mode 100644 index 9b57085..0000000 --- a/src/main/java/io/cloudchains/app/net/xrouter/XRouterPacketManager.java +++ /dev/null @@ -1,263 +0,0 @@ -package io.cloudchains.app.net.xrouter; - -import com.google.common.base.Preconditions; -import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.net.protocols.blocknet.BlocknetParameters; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import org.bitcoinj.core.ECKey; -import org.bitcoinj.core.Sha256Hash; -import org.bitcoinj.core.Utils; - -import javax.annotation.Nullable; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class XRouterPacketManager { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private static final int XROUTER_PACKET_VERSION = 0xff000023; - - private final XRouterMessageSerializer xRouterMessageSerializer; - private final BlocknetParameters blocknetNetworkParameters; - - public XRouterPacketManager(XRouterMessageSerializer xRouterMessageSerializer, BlocknetParameters blocknetNetworkParameters) { - this.xRouterMessageSerializer = xRouterMessageSerializer; - this.blocknetNetworkParameters = blocknetNetworkParameters; - } - - public static int getXRouterPacketVersion() { - return XROUTER_PACKET_VERSION; - } - - private byte[] signPacket(byte[] packetBytes, ECKey ecPrivateKey) { - LOGGER.log(Level.FINER, "[xrouter] DEBUG: Packet bytes: " + new String(Hex.encode(packetBytes))); - Sha256Hash packetHash = Sha256Hash.wrap(Sha256Hash.hash(packetBytes)); - LOGGER.log(Level.FINER, "[xrouter] DEBUG: Packet byte hash: " + packetHash.toString()); - ECKey.ECDSASignature rawSignature = ecPrivateKey.sign(packetHash).toCanonicalised(); - - byte[] r = rawSignature.r.toByteArray(); - byte[] s = rawSignature.s.toByteArray(); - - if (r.length > 32) { - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature R is greater than 32 bytes! Trimming from the beginning. Size: " + r.length); - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature R: " + new String(Hex.encode(r))); - } else if (r.length < 32) { - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature R is less than 32 bytes! Prepending null bytes to the beginning. Size: " + s.length); - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature R: " + new String(Hex.encode(r))); - - r = prependNullTo32(r); - } - - if (s.length > 32) { - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature S is greater than 32 bytes! Trimming from the beginning. Size: " + s.length); - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature S: " + new String(Hex.encode(s))); - } else if (s.length < 32) { - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature S is less than 32 bytes! Prepending null bytes. Size: " + s.length); - LOGGER.log(Level.FINER, "[xrouter] WARNING: Signature S: " + new String(Hex.encode(s))); - - s = prependNullTo32(s); - } - - byte[] signature = new byte[64]; - - System.arraycopy(r, r.length - 32, signature, 0, 32); - System.arraycopy(s, s.length - 32, signature, 32, 32); - - LOGGER.log(Level.FINER, "[xrouter] Signature: " + new String(Hex.encode(signature)) + ", byte length " + signature.length); - - return signature; - } - - private byte[] prependNullTo32(byte[] toPrependTo) { - Preconditions.checkState(toPrependTo.length < 32, "This array is too large (32 bytes or more)"); - - byte[] processed = new byte[32]; - - System.arraycopy(new byte[32 - toPrependTo.length], 0, processed, 0, 32 - toPrependTo.length); - System.arraycopy(toPrependTo, 0, processed, 32 - toPrependTo.length, toPrependTo.length); - - return processed; - } - - private XRouterMessage getPacket(BlocknetPeer blocknetPeer, int size, int commandId, String uuid, ECKey ecPublicKey, ECKey ecPrivateKey, HashMap body) { - Preconditions.checkNotNull(xRouterMessageSerializer); - Preconditions.checkNotNull(blocknetNetworkParameters); - - int extSize = size + 157; - - byte[] xRouterHeaderBytes; - byte compactSize; - int compactSizeBytes; - - if (extSize < 253) { - xRouterHeaderBytes = new byte[158]; - compactSize = (byte) extSize; - //LOGGER.log(Level.FINER, "Compact size = " + compactSize); - xRouterHeaderBytes[0] = compactSize; - compactSizeBytes = 1; - } else if (extSize <= 65535) { - xRouterHeaderBytes = new byte[160]; - compactSize = (byte) 253; - //LOGGER.log(Level.FINER, "Compact size = " + compactSize + ", extSize = " + extSize); - xRouterHeaderBytes[0] = compactSize; - xRouterHeaderBytes[1] = (byte) (0xFF & (extSize)); - xRouterHeaderBytes[2] = (byte) (0xFF & (extSize >> 8)); - compactSizeBytes = 3; - } else { - xRouterHeaderBytes = new byte[162]; - compactSize = (byte) 254; - //LOGGER.log(Level.FINER, "Compact size = " + compactSize + ", extSize = " + extSize); - xRouterHeaderBytes[0] = compactSize; - Utils.uint32ToByteArrayLE(extSize, xRouterHeaderBytes, 1); - compactSizeBytes = 5; - } - - int cursor = compactSizeBytes; - //int cursor = 0; - - Utils.uint32ToByteArrayLE(XROUTER_PACKET_VERSION, xRouterHeaderBytes, cursor); - cursor += 4; - Utils.uint32ToByteArrayLE(commandId, xRouterHeaderBytes, cursor); - cursor += 4; - Utils.uint32ToByteArrayLE(Math.round(System.currentTimeMillis() / 1000), xRouterHeaderBytes, cursor); - cursor += 4; - Utils.uint32ToByteArrayLE(size, xRouterHeaderBytes, cursor); - cursor += 4; - - //fill in reserved header fields with zeros - System.arraycopy(new byte[8], 0, xRouterHeaderBytes, cursor, 8); - cursor += 8; - - System.arraycopy(uuid.getBytes(), 0, xRouterHeaderBytes, cursor, 36); - cursor += 36; - - byte[] pubkey = ecPublicKey.getPubKey(); - - System.arraycopy(pubkey, 0, xRouterHeaderBytes, cursor, 33); - cursor += 33; - - ByteBuffer xRouterHeaderBufSigned = ByteBuffer.allocate(xRouterHeaderBytes.length); - xRouterHeaderBufSigned.put(xRouterHeaderBytes); - xRouterHeaderBufSigned.position(cursor); - - System.arraycopy(new byte[64], 0, xRouterHeaderBytes, cursor, 64); - cursor += 64; - LOGGER.log(Level.FINER, "[xrouter] Serialized XRouter header. Cursor is at " + cursor); - - LOGGER.log(Level.FINER, "[xrouter] Serializing XRouter message (phase 1)."); - XRouterPacketHeader xRouterHeader = new XRouterPacketHeader(ByteBuffer.wrap(xRouterHeaderBytes)); - XRouterMessage message = new XRouterMessage(blocknetPeer, blocknetNetworkParameters, xRouterHeader, body); - - byte[] rawPktUnsigned = message.bitcoinSerialize(); - - byte[] toSign = new byte[rawPktUnsigned.length - compactSizeBytes]; - System.arraycopy(rawPktUnsigned, compactSizeBytes, toSign, 0, toSign.length); - - byte[] signature = signPacket(toSign, ecPrivateKey); - xRouterHeaderBufSigned.put(signature); - - xRouterHeaderBufSigned.flip(); - - LOGGER.log(Level.FINER, "[xrouter] Serializing XRouter message (phase 2)."); - XRouterPacketHeader xRouterHeaderSigned = new XRouterPacketHeader(xRouterHeaderBufSigned); - return new XRouterMessage(blocknetPeer, blocknetNetworkParameters, xRouterHeaderSigned, body); - } - - private HashMap getBody(@Nullable String paymentTx) { - HashMap body = new HashMap<>(); - if (paymentTx != null) - body.put("paymentTx", paymentTx); - - return body; - } - - public XRouterMessage getXrGetBlockCount(BlocknetPeer blocknetPeer, String uuid, String currency, ECKey ecPrivateKey, ECKey ecPublicKey) { - String paymentTx = XRouterFeeUtils.getXRouterFeeTx(blocknetPeer,"xrGetBlockCount"); - - HashMap body = getBody(paymentTx); - body.put("currency", currency); - - int size = currency.length() + 1 + paymentTx.length() + 1 + 4; - - return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetBlockCount"), uuid, ecPublicKey, ecPrivateKey, body); - } - - public XRouterMessage getXrService(BlocknetPeer blocknetPeer, String uuid, String command, ArrayList params, ECKey ecPrivateKey, ECKey ecPublicKey) { - String paymentTx = XRouterFeeUtils.getXRouterFeeTx(blocknetPeer, "xrService"); - - HashMap body = getBody(paymentTx); - body.put("command", command); - body.put("params", params); - - int size = command.length() + 1 + paymentTx.length() + 1 + 4; - - for (Object param : params) { - if (param instanceof Boolean) - size += 4; - else if (param instanceof String) - size += ((String) param).length() + 1; - else if (param instanceof Integer) - size += 4; - else - throw new IllegalArgumentException("Argument of unsupported class: " + param.getClass().getSimpleName()); - } - - return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrService"), uuid, ecPublicKey, ecPrivateKey, body); - } - - public XRouterMessage getXrGetConfig(BlocknetPeer blocknetPeer, String uuid, String address, ECKey ecPrivateKey, ECKey ecPublicKey) { - HashMap body = getBody(null); - body.put("addr", address); - - int size = address.length() + 1; - - return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetConfig"), uuid, ecPublicKey, ecPrivateKey, body); - } - - public XRouterMessage getXrSendTransaction(BlocknetPeer blocknetPeer, String uuid, String feePayment, String currency, String transaction, ECKey ecPrivateKey, ECKey ecPublicKey) { - HashMap body = getBody(feePayment); - body.put("currency", currency); - body.put("transaction", transaction); - - int size = feePayment.length() + 1 + currency.length() + 1 + 4 + transaction.length() + 1; - - return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrSendTransaction"), uuid, ecPublicKey, ecPrivateKey, body); - } - - public XRouterMessage getXrGetBlockHash(BlocknetPeer blocknetPeer, String uuid, String feePayment, String currency, String blockIndex, ECKey ecPrivateKey, ECKey ecPublicKey) { - HashMap body = getBody(feePayment); - body.put("currency", currency); - body.put("blockId", blockIndex); - - int size = feePayment.length() + 1 + currency.length() + 1 + 4 + blockIndex.length() + 1; - - return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetBlockHash"), uuid, ecPublicKey, ecPrivateKey, body); - } - - public XRouterMessage getXrGetBlock(BlocknetPeer blocknetPeer, String uuid, String feePayment, String currency, String blockHash, ECKey ecPrivateKey, ECKey ecPublicKey) { - HashMap body = getBody(feePayment); - body.put("currency", currency); - body.put("blockHash", blockHash); - - int size = feePayment.length() + 1 + currency.length() + 1 + blockHash.length() + 1 + 4; - - return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetBlock"), uuid, ecPublicKey, ecPrivateKey, body); - } - - public XRouterMessage getXrGetTransaction(BlocknetPeer blocknetPeer, String uuid, String currency, String txid, ECKey ecPrivateKey, ECKey ecPublicKey) { - String paymentTx = XRouterFeeUtils.getXRouterFeeTx(blocknetPeer, "xrGetBlockCount"); - - HashMap body = getBody(paymentTx); - body.put("currency", currency); - body.put("txid", txid); - - int size = paymentTx.length() + 1 + currency.length() + 1 + txid.length() + 1 + 4; - - return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetTransaction"), uuid, ecPublicKey, ecPrivateKey, body); - } -} diff --git a/src/main/java/io/cloudchains/app/util/AddressBalance.java b/src/main/java/io/cloudchains/app/util/AddressBalance.java deleted file mode 100644 index 61bf335..0000000 --- a/src/main/java/io/cloudchains/app/util/AddressBalance.java +++ /dev/null @@ -1,139 +0,0 @@ -package io.cloudchains.app.util; - -import com.google.common.base.Preconditions; -import com.google.common.util.concurrent.AtomicDouble; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.DumpedPrivateKey; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -public class AddressBalance { - private Address address; - private DumpedPrivateKey privateKey; - private AtomicReference addrProp = null; - private AtomicDouble balanceProp = null; - private ArrayList utxos = null; - - public AddressBalance(Address address, DumpedPrivateKey privateKey) { - this.address = address; - this.privateKey = privateKey; - setAddrProp(address.toBase58()); - } - - public Address getAddress() { - return address; - } - - public String getAddrProp() { - return addrProperty().get(); - } - - private void setAddrProp(String value) { - addrProperty().set(value); - } - - private AtomicReference addrProperty() { - if (addrProp == null) - addrProp = new AtomicReference("addrProp"); - return addrProp; - } - - public double getBalanceProp() { - return balanceProperty().get(); - } - - private void setBalanceProp(double value) { - balanceProperty().set(value); - } - - public AtomicDouble balanceProperty() { - if (balanceProp == null) - balanceProp = new AtomicDouble(0); - return balanceProp; - } - - public DumpedPrivateKey getPrivateKey() { - return privateKey; - } - - public void clearUtxos() { - if (utxos == null) - return; - - utxos.removeIf(utxo -> !utxo.isSpent()); - } - - public boolean addUtxo(UTXO utxo) { - Preconditions.checkNotNull(utxo); - if (this.utxos == null) - this.utxos = new ArrayList<>(); - - // Only add UTXO's that do not exist in our wallet - UTXO bUtxo = getUtxo(utxo.getTxid(), utxo.getVout()); - if (bUtxo == null) - this.utxos.add(utxo); - else - return false; - - calculateBalance(); - return true; - } - - public void setUtxos(ArrayList recvUtxos) { - ArrayList newUtxos = new ArrayList<>(); - - if (this.utxos != null && this.utxos.size() > 0) { - for (UTXO utxo : recvUtxos) { - for (UTXO bUtxo : this.utxos) { - if (!utxo.getTxid().equals(bUtxo.getTxid()) || utxo.getVout() != bUtxo.getVout()) { - newUtxos.add(utxo); - } - } - } - - if (newUtxos.size() > 0) { - this.utxos = newUtxos; - } - } else { - this.utxos = recvUtxos; - } - - calculateBalance(); - } - - private UTXO getUtxo(String txid, int vout) { - return utxos.stream().filter(o -> o.getTxid().equals(txid) && o.getVout() == vout).findFirst().orElse(null); - } - - public List getSpentUtxos() { - if (utxos == null) - utxos = new ArrayList<>(); - - return utxos.stream().filter(UTXO::isSpent).collect(Collectors.toList()); - } - - public List getUtxos() { - if (utxos == null) - utxos = new ArrayList<>(); - - return utxos.stream().filter(utxo -> !utxo.isSpent()).collect(Collectors.toList()); - } - - public void calculateBalance() { - Preconditions.checkNotNull(utxos); - double balance = 0; - - for (UTXO utxo : utxos) { - if (!utxo.isSpent()) { - balance += utxo.getValue(); - } - } - - balance /= 100000000.0; - setBalanceProp(balance); - } - -} diff --git a/src/main/java/io/cloudchains/app/util/AddressDiscoveryService.java b/src/main/java/io/cloudchains/app/util/AddressDiscoveryService.java deleted file mode 100644 index 05af66d..0000000 --- a/src/main/java/io/cloudchains/app/util/AddressDiscoveryService.java +++ /dev/null @@ -1,265 +0,0 @@ -package io.cloudchains.app.util; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.api.http.client.HTTPClient; -import org.bitcoinj.core.Address; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class AddressDiscoveryService { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - // Simplified configuration values - private static final int GAP_LIMIT = 25; - private static final int BATCH_SIZE = 100; - private static final int MAX_DISCOVERY_DEPTH = 10000; - private static final int DISCOVERY_TIMEOUT_MS = 30000; // 30 seconds max - private static final int MAX_CONSECUTIVE_FAILURES = 3; - - private final CoinInstance coinInstance; - private final HTTPClient httpClient; - private final ConfigHelper configHelper; - private final String currencyString; - - // Enhanced logging helper - private String getLogPrefix() { - return "[discovery-" + currencyString + "]"; - } - - public AddressDiscoveryService(CoinInstance coinInstance) { - this.coinInstance = coinInstance; - this.httpClient = new HTTPClient(5); - this.configHelper = coinInstance.getConfigHelper(); - this.currencyString = CoinTickerUtils.tickerToString(coinInstance.getTicker()); - LOGGER.log(Level.INFO, getLogPrefix() + " AddressDiscoveryService initialized for " + currencyString); - } - - /** - * Main discovery method - determines correct addressCount based on last used address with funds + 1 - */ - public int discoverAddressCount() { - LOGGER.log(Level.INFO, getLogPrefix() + " Starting address discovery for " + currencyString); - - long discoveryStartTime = System.currentTimeMillis(); - int consecutiveFailures = 0; - int lastUsedIndex = -1; - int consecutiveEmpty = 0; - int currentAddressCount = configHelper.getAddressCount(); - int batchStart = currentAddressCount; - - LOGGER.log(Level.INFO, getLogPrefix() + " Starting discovery from address index: " + currentAddressCount); - - try { - while (consecutiveEmpty < GAP_LIMIT && batchStart < MAX_DISCOVERY_DEPTH) { - // Check for discovery timeout - long elapsedTime = System.currentTimeMillis() - discoveryStartTime; - if (elapsedTime > DISCOVERY_TIMEOUT_MS) { - LOGGER.log(Level.WARNING, getLogPrefix() + " Discovery timeout reached after " + - (elapsedTime / 1000) + " seconds, aborting discovery"); - return configHelper.getAddressCount(); - } - - // Check for consecutive failures (circuit breaker) - if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - LOGGER.log(Level.SEVERE, getLogPrefix() + " Maximum consecutive failures (" + - MAX_CONSECUTIVE_FAILURES + ") reached, aborting discovery"); - return configHelper.getAddressCount(); - } - - // Progress logging every 5 batches - if (batchStart > currentAddressCount && batchStart % (BATCH_SIZE * 5) == 0) { - LOGGER.log(Level.INFO, getLogPrefix() + " Discovery progress: " + batchStart + - " addresses checked, " + consecutiveEmpty + " consecutive empty"); - } - - LOGGER.log(Level.FINE, getLogPrefix() + " Processing batch starting at index " + batchStart); - - // Generate batch of addresses - List batch = generateAddressBatch(batchStart, BATCH_SIZE); - - // Check for UTXOs in batch - List batchUtxos = checkBatchForUtxos(batch); - - // Handle HTTP failures with circuit breaker - if (batchUtxos == null) { - consecutiveFailures++; - LOGGER.log(Level.WARNING, getLogPrefix() + " HTTP failure " + consecutiveFailures + - "/" + MAX_CONSECUTIVE_FAILURES + " for batch starting at " + batchStart); - // Continue to next batch instead of failing immediately - batchStart += BATCH_SIZE; - continue; - } else { - consecutiveFailures = 0; // Reset failure count on success - } - - if (!batchUtxos.isEmpty()) { - // Found UTXOs - update last used index - int batchLastUsedIndex = findLastUsedIndex(batch, batchUtxos); - int globalLastUsedIndex = batchStart + batchLastUsedIndex; - lastUsedIndex = Math.max(lastUsedIndex, globalLastUsedIndex); - consecutiveEmpty = 0; - - LOGGER.log(Level.INFO, getLogPrefix() + " Found UTXOs in batch, last used index: " + - globalLastUsedIndex + ", batch range: " + batchStart + "-" + - (batchStart + BATCH_SIZE - 1)); - } else { - consecutiveEmpty += BATCH_SIZE; - LOGGER.log(Level.INFO, getLogPrefix() + " Empty batch (addresses " + batchStart + "-" + - (batchStart + BATCH_SIZE - 1) + "), consecutive empty: " + consecutiveEmpty); - } - - batchStart += BATCH_SIZE; - - // Safety check for max depth - if (batchStart >= MAX_DISCOVERY_DEPTH) { - LOGGER.log(Level.WARNING, getLogPrefix() + " Hit max discovery depth at " + MAX_DISCOVERY_DEPTH); - break; - } - } - - // Calculate final address count: last used address with funds detected + 1 - int finalCount; - if (lastUsedIndex >= 0) { - // Found used addresses, set to last used + 1 - finalCount = lastUsedIndex + 1; - LOGGER.log(Level.INFO, getLogPrefix() + " Found used addresses, setting address count to: " + finalCount); - } else { - // No used addresses found, keep current config value - finalCount = configHelper.getAddressCount(); - LOGGER.log(Level.INFO, getLogPrefix() + " No used addresses found, keeping current address count: " + finalCount); - } - - LOGGER.log(Level.INFO, getLogPrefix() + " Discovery complete for " + currencyString + - ". Last used index: " + lastUsedIndex + ", final address count: " + finalCount); - - return finalCount; - - } catch (Exception e) { - LOGGER.log(Level.SEVERE, getLogPrefix() + " Error during discovery for " + currencyString, e); - return configHelper.getAddressCount(); - } - } - - /** - * Generate a batch of addresses starting from a specific index - */ - private List generateAddressBatch(int startIndex, int batchSize) { - List batch = new ArrayList<>(); - - // Ensure we have enough addresses generated - int currentGenerated = coinInstance.getAddressKeyPairs().size(); - int needed = startIndex + batchSize; - - if (needed > currentGenerated) { - // Generate additional addresses starting from currentGenerated - for (int i = currentGenerated; i < needed; i++) { - AddressBalance addr = coinInstance.generateAddress(false); - // Don't add to batch here - we'll extract the correct slice below - } - LOGGER.log(Level.INFO, getLogPrefix() + " Generated " + (needed - currentGenerated) + - " new addresses for " + currencyString); - } - - // Always extract the batch from the correct startIndex range - for (int i = startIndex; i < needed; i++) { - batch.add(coinInstance.getAddressKeyPairs().get(i)); - } - - return batch; - } - - /** - * Check a batch of addresses for UTXOs - */ - private List checkBatchForUtxos(List batch) { - if (batch.isEmpty()) { - return new ArrayList<>(); - } - - // Extract addresses for UTXO query - String[] addresses = batch.stream() - .map(addr -> addr.getAddress().toBase58()) - .toArray(String[]::new); - - JsonArray utxoResponse = null; - try { - utxoResponse = httpClient.getUtxosUncached(coinInstance.getTicker(), addresses); - } catch (Exception e) { - LOGGER.log(Level.SEVERE, getLogPrefix() + " HTTP request failed for addresses " + - addresses[0] + "..." + addresses[addresses.length - 1], e); - return null; // Signal failure to caller - } - - if (utxoResponse == null || utxoResponse.size() == 0) { - return new ArrayList<>(); - } - - List utxos = new ArrayList<>(); - for (JsonElement element : utxoResponse) { - try { - JsonObject utxoJson = element.getAsJsonObject(); - UTXO utxo = new UTXO( - coinInstance.getTicker(), - utxoJson.get("address").getAsString(), - utxoJson.get("txid").getAsString(), - utxoJson.get("vout").getAsInt(), - utxoJson.get("confirmations").getAsInt(), - (long) (utxoJson.get("value").getAsDouble() * 100000000.0) - ); - utxos.add(utxo); - } catch (Exception e) { - LOGGER.log(Level.WARNING, getLogPrefix() + " Failed to parse UTXO response element", e); - // Continue processing other UTXOs instead of failing completely - } - } - - return utxos; - } - - /** - * Find the last used address index in the batch - * Uses HashMap for O(1) lookups when batch size is large enough to benefit - */ - private int findLastUsedIndex(List batch, List utxos) { - // Use HashMap for O(1) lookups when batch is large enough to benefit - if (batch.size() > 20) { - Map addressToIndex = new HashMap<>(batch.size()); - for (int i = 0; i < batch.size(); i++) { - addressToIndex.put(batch.get(i).getAddress().toBase58(), i); - } - - int lastIndex = 0; - for (UTXO utxo : utxos) { - Integer index = addressToIndex.get(utxo.getAddress()); - if (index != null) { - lastIndex = Math.max(lastIndex, index); - } - } - return lastIndex; - } else { - // For small batches, linear search is faster due to cache locality - int lastIndex = 0; - for (UTXO utxo : utxos) { - for (int i = 0; i < batch.size(); i++) { - if (batch.get(i).getAddress().toBase58().equals(utxo.getAddress())) { - lastIndex = Math.max(lastIndex, i); - break; - } - } - } - return lastIndex; - } - } -} \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/util/CCLogger.java b/src/main/java/io/cloudchains/app/util/CCLogger.java deleted file mode 100644 index 6df69f5..0000000 --- a/src/main/java/io/cloudchains/app/util/CCLogger.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.cloudchains.app.util; - -import java.io.*; - -public class CCLogger { - private static boolean isLogging; - - public static boolean isLoggingEnabled() { - return isLogging; - } - - static { - System.setProperty("java.util.logging.SimpleFormatter.format", "[%4$s] %5$s %n"); - } - - public static void setLogging(boolean isEnabled) { -// System.setErr(new PrintStream(new OutputStream() { -// public void write(int b) { -// } -// })); - - isLogging = isEnabled; - } -} diff --git a/src/main/java/io/cloudchains/app/util/ConfigHelper.java b/src/main/java/io/cloudchains/app/util/ConfigHelper.java deleted file mode 100644 index 1aa8ca2..0000000 --- a/src/main/java/io/cloudchains/app/util/ConfigHelper.java +++ /dev/null @@ -1,243 +0,0 @@ -package io.cloudchains.app.util; - -import com.google.common.base.Preconditions; -import org.json.JSONObject; - -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.nio.file.Files; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class ConfigHelper { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private String tickerStr; - private File file; - private FileWriter fileWriter; - - private double fee; - private boolean feeFlat; - private boolean rpcEnabled; - private String rpcUsername; - private String rpcPassword; - private int rpcPort; - private int addressCount; - - // Override specific configuration directory (useful in unit tests) - public static String CONFIG_DIR = ""; // Must not end with [/], e.g. /home/user/.config, not /home/user/.config/ - - public ConfigHelper(String tickerStr) { - this.tickerStr = tickerStr; - - try { - file = Preconditions.checkNotNull(this.getFile()); - loadConfig(); - } catch (Exception e) { - e.printStackTrace(); - } - } - - public void loadConfig() { - try { - String rawConfig = new String(Files.readAllBytes(file.toPath())); - if (rawConfig.isEmpty()) { - fee = 0.0001; - feeFlat = true; - rpcEnabled = false; - rpcUsername = ""; - rpcPassword = ""; - if (this.tickerStr.equalsIgnoreCase("master")) { - rpcPort = 9955; - } else { - rpcPort = -1000; - } - addressCount = 0; - - writeConfig(); - return; - } - - JSONObject config = new JSONObject(rawConfig); - - final String[] configKeys = new String[] { - "fee", - "feeFlat", - "rpcEnabled", - "rpcUsername", - "rpcPassword", - "rpcPort", - "addressCount" - }; - - for (String configKey : configKeys) { - if (!config.has(configKey)) { - LOGGER.log(Level.FINER, "[config] Warning: Configuration file does not contain required value '" + configKey + "'. This will probably break things later on."); - } - } - - fee = config.getDouble("fee"); - feeFlat = config.getBoolean("feeFlat"); - rpcEnabled = config.getBoolean("rpcEnabled"); - rpcUsername = config.getString("rpcUsername"); - rpcPassword = config.getString("rpcPassword"); - rpcPort = config.getInt("rpcPort"); - - if (!config.has("addressCount")) { - setAddressCount(0); - writeConfig(); - } else { - addressCount = config.getInt("addressCount"); - } - } catch (Exception e) { - LOGGER.log(Level.FINER, "[config] ERROR: Error while reading config file!"); - e.printStackTrace(); - } - } - - private File getFile() { - String userHome = getLocalDataDirectory(); - Preconditions.checkNotNull(userHome); - - File home = new File(userHome); - File settingsDirectory = new File(home, "settings"); - if (!settingsDirectory.exists()) { - if (!settingsDirectory.mkdirs()) { - LOGGER.log(Level.FINER, "[config] ERROR: Could not create base/settings directory!"); - return null; - } - } - - File configFile = new File(settingsDirectory, "config-" + tickerStr + ".json"); - try { - if (!configFile.createNewFile() && !configFile.exists()) - return null; - } catch (IOException e) { - e.printStackTrace(); - } - - return configFile; - } - - public void setFee(double fee) { - this.fee = fee; - } - - public void setFlatFee(boolean flat) { - this.feeFlat = flat; - } - - public void setRpcEnabled(boolean isEnabled) { - this.rpcEnabled = isEnabled; - } - - public void setRpcUsername(String user) { - this.rpcUsername = user; - } - - public void setRpcPassword(String pass) { - this.rpcPassword = pass; - } - - public void setRpcPort(int rpcPort) { - if (PortCheck.available(rpcPort)) - this.rpcPort = rpcPort; - else - setRpcPort(rpcPort + 1); - } - - public void setAddressCount(int addressCount) { - this.addressCount = addressCount; - } - - public double getFee() { - return fee; - } - - public boolean isFlatFee() { - return feeFlat; - } - - public boolean isRpcEnabled() { - return rpcEnabled; - } - - public String getRpcUsername() { - return rpcUsername; - } - - public String getRpcPassword() { - return rpcPassword; - } - - public int getMasterRpcPort() { - if (rpcPort == -1000) { - rpcPort = 9955; - } - - return rpcPort; - } - - public int getRpcPort() { - return rpcPort; - } - - public int getAddressCount() { - return addressCount; - } - - public boolean validAuth() { - return rpcUsername != null && !rpcUsername.equals("") && rpcPassword != null && !rpcPassword.equals(""); - } - - public void writeConfig() { - try { - fileWriter = new FileWriter(file, false); - - JSONObject config = new JSONObject(); - config.put("fee", fee); - config.put("feeFlat", feeFlat); - config.put("rpcEnabled", rpcEnabled); - config.put("rpcUsername", rpcUsername); - config.put("rpcPassword", rpcPassword); - config.put("rpcPort", rpcPort); - config.put("addressCount", addressCount); - - fileWriter.write(config.toString(4)); - fileWriter.flush(); - fileWriter.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public static String getLocalDataDirectory() { - String userHomeDir; - if (CONFIG_DIR.isEmpty()) { - String OS = (System.getProperty("os.name")).toLowerCase(); - - if (OS.contains("win")) { - userHomeDir = System.getenv("AppData"); - } else if (OS.contains("nix") || OS.contains("nux") || OS.contains("aix")) { - userHomeDir = System.getProperty("user.home") + File.separator + ".config"; - } else if (OS.contains("mac")) { - userHomeDir = System.getProperty("user.home") + File.separator + "Library" + File.separator + "Application Support"; - } else { - userHomeDir = System.getProperty("user.home") + File.separator + ".config"; - } - userHomeDir += File.separator + "CloudChains" + File.separator; - } else { - userHomeDir = CONFIG_DIR + File.separator + "CloudChains" + File.separator; - } - - File directory = new File(userHomeDir); - if (!directory.exists()) { - directory.mkdir(); - } - - return userHomeDir; - } -} diff --git a/src/main/java/io/cloudchains/app/util/UTXO.java b/src/main/java/io/cloudchains/app/util/UTXO.java deleted file mode 100644 index 680e929..0000000 --- a/src/main/java/io/cloudchains/app/util/UTXO.java +++ /dev/null @@ -1,82 +0,0 @@ -package io.cloudchains.app.util; - -import com.google.gson.annotations.SerializedName; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.Coin; -import org.bitcoinj.core.Sha256Hash; -import org.bitcoinj.core.Transaction; -import org.bitcoinj.script.Script; -import org.bitcoinj.script.ScriptBuilder; - -public class UTXO { - @SerializedName("address") private String addressB58; - @SerializedName("txhash") private String txid; - @SerializedName("block_number") private int height; - protected transient long value; - private int vout; - private boolean spent; - protected CoinTicker ticker; - - public UTXO(CoinTicker ticker, String addressB58, String txid, int vout, int blockHeight, long value) { - this.addressB58 = addressB58; - this.txid = txid; - this.vout = vout; - this.height = blockHeight; - this.value = value; - this.spent = false; - - this.ticker = ticker; - } - - public void setSpent(boolean spentBool) { - spent = spentBool; - } - - public String getAddress() { - return addressB58; - } - - public String getTxid() { - return txid; - } - - public long getValue() { - return value; - } - - public int getVout() { - return vout; - } - - public int getHeight() { - return height; - } - - public boolean isSpent() { - return spent; - } - - public double getAmount() { - return getValue() / 100000000.0; - } - - public org.bitcoinj.core.UTXO createUTXO() { - Address address = Address.fromBase58(CoinInstance.getInstance(this.ticker).getNetworkParameters(), getAddress()); - - Script scriptForUTXO = ScriptBuilder.createOutputScript(address); - - Sha256Hash sha256Hash = Sha256Hash.wrap(getTxid()); - return new org.bitcoinj.core.UTXO(sha256Hash, getVout(), Coin.valueOf(getValue()), getHeight(), false, scriptForUTXO, getAddress()); - } - - public String toString() { - return "\n-------------------------\n" + - "Address=" + getAddress() + - "\nTXHash=" + getTxid() + - "\nVout=" + getVout() + - "\nBlockNumber=" + getHeight() + - "\nValue=" + getValue(); - } -} diff --git a/src/main/java/io/cloudchains/app/util/Utility.java b/src/main/java/io/cloudchains/app/util/Utility.java deleted file mode 100644 index 0048df2..0000000 --- a/src/main/java/io/cloudchains/app/util/Utility.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.cloudchains.app.util; - -import org.bitcoinj.core.Address; -import org.bitcoinj.core.AddressFormatException; -import org.bitcoinj.core.NetworkParameters; - -public class Utility { - public static boolean isValidAddress(NetworkParameters params, String address) { - try { - Address.fromBase58(params, address); - return true; - } catch(AddressFormatException e) { - return false; - } - } -} diff --git a/src/main/java/io/cloudchains/app/util/XRouterConfiguration.java b/src/main/java/io/cloudchains/app/util/XRouterConfiguration.java deleted file mode 100644 index a9e6e2b..0000000 --- a/src/main/java/io/cloudchains/app/util/XRouterConfiguration.java +++ /dev/null @@ -1,217 +0,0 @@ -package io.cloudchains.app.util; - -import com.google.common.collect.HashBiMap; - -import java.io.IOException; -import java.io.StringReader; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Properties; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class XRouterConfiguration { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - private final String rawXRouterConfig; - private final HashMap feeMap = new HashMap<>(); - private final ArrayList supportedWallets = new ArrayList<>(); - private String feeAddress; - private int timeout; - private int blockLimit; - - private static HashBiMap pluginParamTypes; - - static { - pluginParamTypes = HashBiMap.create(2); - - pluginParamTypes.put("string", String.class); - pluginParamTypes.put("int", Integer.class); - pluginParamTypes.put("bool", Boolean.class); - } - - public static String getStringByClass(Class clazz) { - return pluginParamTypes.inverse().get(clazz); - } - - public static class XRouterPluginConfiguration { - private final String rawPluginConfig; - private final String pluginName; - - private ArrayList paramTypes = new ArrayList<>(); - private double fee; - private int clientRequestLimit; - - public XRouterPluginConfiguration(String pluginName, String rawPluginConfig) { - this.pluginName = pluginName; - this.rawPluginConfig = rawPluginConfig; - } - - public void parsePluginConfig() { - Properties properties; - try { - properties = getPluginProperties(rawPluginConfig); - } catch (IOException e) { - LOGGER.log(Level.FINER, "[xrouter-plugin-config-parser] ERROR: Error while parsing plugin config!"); - e.printStackTrace(); - return; - } - - if (!properties.containsKey("parameters") && !properties.containsKey("paramsType")) { - LOGGER.log(Level.FINER, "[xrouter-plugin-config-parser] ERROR: Plugin has no parameters!"); - } else { - String[] rawParamTypes; - - if (properties.containsKey("parameters")) - rawParamTypes = properties.getProperty("parameters").split(","); - else if (properties.containsKey("paramsType")) - rawParamTypes = properties.getProperty("paramsType").split(","); - else - return; - - for (String rawParamType : rawParamTypes) { - if (rawParamType.isEmpty()) - continue; - - if (!pluginParamTypes.containsKey(rawParamType)) { - LOGGER.log(Level.FINER, "[xrouter-plugin-config-parser] ERROR: Invalid/unsupported plugin parameter type: " + rawParamType + ". Failing."); - throw new IllegalArgumentException("Invalid/unsupported plugin parameter type: " + rawParamType); - } - - paramTypes.add(pluginParamTypes.get(rawParamType)); - } - } - - if (properties.contains("fee")) { - fee = Double.parseDouble(properties.getProperty("fee")); - } else { - fee = 0; - } - - if (properties.contains("clientrequestlimit")) { - clientRequestLimit = Integer.parseInt(properties.getProperty("clientrequestlimit")); - } else { - clientRequestLimit = 100; - } - - LOGGER.log(Level.FINER, "[xrouter-plugin-config-parser] Processing '" + pluginName + "' complete."); - LOGGER.log(Level.FINER, "[xrouter-plugin-config-parser] DEBUG: " + pluginName + ": fee = " + fee); - LOGGER.log(Level.FINER, "[xrouter-plugin-config-parser] DEBUG: " + pluginName + ": params = "); - for (int i = 0; i < paramTypes.size(); i++) { - LOGGER.log(Level.FINER, "Parameter " + i + ":\t" + paramTypes.get(i).getSimpleName()); - } - LOGGER.log(Level.FINER, "[xrouter-plugin-config-parser] DEBUG: " + pluginName + ": clientRequestLimit = " + clientRequestLimit); - } - - private static Properties getPluginProperties(String rawConfig) throws IOException { - String toRead = rawConfig.replace("\\n", "\n"); - - Properties properties = new Properties(); - properties.load(new StringReader(toRead)); - return properties; - } - - public String getPluginName() { - return pluginName; - } - - public ArrayList getParamTypes() { - return paramTypes; - } - - public double getFee() { - return fee; - } - - public int getClientRequestLimit() { - return clientRequestLimit; - } - } - - public XRouterConfiguration(String rawXRouterConfig) { - this.rawXRouterConfig = rawXRouterConfig; - } - - public void parseConfig() { - HashMap properties = getProperties(rawXRouterConfig); - - if (properties == null) - return; - - LOGGER.log(Level.FINER, "[xrouter-config-parser] DEBUG: Properties: " + properties.toString()); - - supportedWallets.addAll(Arrays.asList(((String) properties.get("Main").get("wallets")).split(","))); - timeout = Integer.parseInt((String) properties.get("Main").get("timeout")); - blockLimit = Integer.parseInt((String) properties.get("Main").get("blocklimit")); - feeAddress = (String) properties.get("Main").get("paymentaddress"); - - for (String key : properties.keySet()) { - if (key.startsWith("xr")) { - Properties xRouterPropertySet = properties.get(key); - double fee = Double.parseDouble((String) xRouterPropertySet.get("fee")); - feeMap.put(key, fee); - } - } - - LOGGER.log(Level.FINER, "[xrouter-config-parser] Processing complete."); - } - - public ArrayList getSupportedWallets() { - return supportedWallets; - } - - public HashMap getFeeMap() { - return feeMap; - } - - public String getFeeAddress() { - return feeAddress; - } - - public int getBlockLimit() { - return blockLimit; - } - - public int getTimeout() { - return timeout; - } - - private static HashMap parseINI(String toRead) throws IOException { - HashMap result = new HashMap<>(); - new Properties() { - - private Properties section; - - @Override - public Object put(Object key, Object value) { - String header = (key + " " + value).trim(); - if (header.startsWith("[") && header.endsWith("]")) - return result.put(header.substring(1, header.length() - 1), - section = new Properties()); - else - return section.put(key, value); - } - - }.load(new StringReader(toRead)); - return result; - } - - private static HashMap getProperties(String rawConfig) { - String formatted = rawConfig.replace("\\n", "\n"); - - HashMap properties; - - try { - properties = parseINI(formatted); - } catch (IOException e) { - LOGGER.log(Level.FINER, "[xrouter-config-parser] ERROR: Error while parsing XRouter config!"); - e.printStackTrace(); - return null; - } - - return properties; - } -} diff --git a/src/main/java/io/cloudchains/app/util/background/BackgroundTimerThread.java b/src/main/java/io/cloudchains/app/util/background/BackgroundTimerThread.java deleted file mode 100644 index 767a727..0000000 --- a/src/main/java/io/cloudchains/app/util/background/BackgroundTimerThread.java +++ /dev/null @@ -1,169 +0,0 @@ -package io.cloudchains.app.util.background; - -import io.cloudchains.app.App; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.api.http.client.HTTPClient; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeerGroup; -import io.cloudchains.app.util.XRouterConfiguration; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.logging.Level; -import java.util.logging.LogManager; -import java.util.logging.Logger; - -public class BackgroundTimerThread implements Runnable { - private final static LogManager LOGMANAGER = LogManager.getLogManager(); - private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); - - public static final boolean HTTP_BLOCK_COUNT_UPDATES = true; - public static final boolean HTTP_BALANCE_UPDATES = true; - - private static final int KEEPALIVE_INTERVAL = 10000; - private static final int BALANCE_INTERVAL = 10000; - - private ExecutorService threadPool = Executors.newSingleThreadExecutor(); - - private BlocknetPeerGroup blocknetPeerGroup; - private HTTPClient feeUpdateHttpClient; - private HTTPClient heightUpdateHttpClient; - - private long lastKeepAliveTime; - private long lastBalanceUpdateTime; - - private long lastOut; - private boolean shutdownRequested = false; - - public BackgroundTimerThread() { - blocknetPeerGroup = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()).getBlocknetPeerGroup(); - feeUpdateHttpClient = App.feeUpdateHttpClient; - heightUpdateHttpClient = App.heightUpdateHttpClient; - - lastKeepAliveTime = 0; - lastBalanceUpdateTime = 0; - - lastOut = 0; - } - - public void stop() { - shutdownRequested = true; - } - - private void outputAvailableCurrencies() { - long elapsed = (System.currentTimeMillis() - lastOut); - - if (elapsed < 60 * 1000 && lastOut != 0) - return; - - for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { - if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) - continue; - - if (CoinInstance.getBlockCountByTicker(coinInstance.getTicker()) > 0) { - LOGGER.log(Level.INFO, "[coin] Available Currency: " + CoinTickerUtils.tickerToString(coinInstance.getTicker())); - } - } - - lastOut = System.currentTimeMillis(); - } - - private void sendKeepAlive() { - long elapsed = (System.currentTimeMillis() - lastKeepAliveTime); - - if (elapsed < KEEPALIVE_INTERVAL && lastKeepAliveTime != 0) - return; - - if (HTTP_BLOCK_COUNT_UPDATES) { - heightUpdateHttpClient.getAllBlockCounts(); - feeUpdateHttpClient.getAllFees(); - } else if (!blocknetPeerGroup.getConnectedPeers().isEmpty()) { - for (BlocknetPeer blocknetPeer : blocknetPeerGroup.getConnectedPeers()) { - XRouterConfiguration xRouterConfiguration = blocknetPeer.getxRouterConfiguration(); - if (xRouterConfiguration == null) - continue; - - for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { - if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) - continue; - else if (!blocknetPeer.getxRouterConfiguration().getSupportedWallets().contains(coinInstance.getNetworkParameters().getId())) - continue; - - coinInstance.sendXrGetBlockCount(blocknetPeer); - LOGGER.log(Level.FINER, "[BackgroundTimer] Sent keepalive message: " + coinInstance.getNetworkParameters().getId()); - } - } - } else { - return; - } - - lastKeepAliveTime = System.currentTimeMillis(); - } - - private void sendBalanceUpdate() { - long elapsed = (System.currentTimeMillis() - lastBalanceUpdateTime); - - if (elapsed < BALANCE_INTERVAL && lastBalanceUpdateTime != 0) - return; - - // No longer polling balances and transaction history here. Instead it is requested - // on demand when client requests the data. See HTTPServerHandler.java:302-330 - - for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { - if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) - continue; - - if (CoinInstance.getBlockCountByTicker(coinInstance.getTicker()) <= 0) { - continue; - } - - if (blocknetPeerGroup.getConnectedPeers().isEmpty()) { - return; - } - - BlocknetPeer blocknetPeer = blocknetPeerGroup.getBestBlocknetPeer(coinInstance.getNetworkParameters().getId()); - if (blocknetPeer == null) { - LOGGER.log(Level.FINER, "[BackgroundTimer] Peer was not found for currency " + coinInstance.getNetworkParameters().getId()); - continue; - } - - coinInstance.sendXrGetUtxos(blocknetPeer); - LOGGER.log(Level.FINER, "[BackgroundTimer] Sent GetUtxos message: " + coinInstance.getNetworkParameters().getId()); - } - - lastBalanceUpdateTime = System.currentTimeMillis(); - } - - @Override - public void run() { - LOGGER.log(Level.FINER, "[BackgroundTimer] Waiting until initial messages are sent off."); - - for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { - if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) - continue; - - new Thread(() -> { - App.feeUpdateHttpClient.getHistory(coinInstance.getTicker(), 0, (int) System.currentTimeMillis(), 30000); - }).start(); - } - - while (!Thread.currentThread().isInterrupted()) { - if (shutdownRequested) - break; - try { - sendKeepAlive(); - outputAvailableCurrencies(); - - Thread.sleep(100); - } catch (NullPointerException e) { - e.printStackTrace(); - } catch (Exception e) { - LOGGER.log(Level.FINER, "[BackgroundTimer] Interrupted thread"); - e.printStackTrace(); - Thread.currentThread().interrupt(); - } - } - } -} diff --git a/src/main/java/io/cloudchains/app/wallet/WalletHelper.java b/src/main/java/io/cloudchains/app/wallet/WalletHelper.java deleted file mode 100644 index b61f662..0000000 --- a/src/main/java/io/cloudchains/app/wallet/WalletHelper.java +++ /dev/null @@ -1,285 +0,0 @@ -package io.cloudchains.app.wallet; - -import com.google.common.base.Preconditions; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; -import io.cloudchains.app.util.AddressBalance; -import io.cloudchains.app.util.CloudTransaction; -import io.cloudchains.app.util.UTXO; -import org.bitcoinj.core.*; -import org.bitcoinj.core.Base58; -import org.bitcoinj.crypto.DeterministicKey; -import org.bitcoinj.script.Script; -import org.bitcoinj.script.ScriptBuilder; -import org.bitcoinj.wallet.Wallet; -import java.text.DecimalFormat; -import java.util.ArrayList; -import java.util.Comparator; - -public class WalletHelper { - private CoinInstance coin; - private NetworkParameters networkParameters; - - public WalletHelper(CoinInstance coinInstance) { - this.coin = coinInstance; - this.networkParameters = coin.getNetworkParameters(); - - } - - public Transaction createRawTransactionWithAllUTXOs(Transaction tx, double amount) { - try { - ArrayList utxos = coinSelector(amount); - - Preconditions.checkNotNull(utxos); - for (UTXO utxo : utxos) { - if (utxo.isSpent()) - continue; - - org.bitcoinj.core.UTXO bUtxo = utxo.createUTXO(); - AddressBalance addressBalance = coin.getAddressBalance(utxo.getAddress()); - Preconditions.checkNotNull(addressBalance); - - TransactionOutPoint outPoint = new TransactionOutPoint(networkParameters, bUtxo.getIndex(), bUtxo.getHash()); - - tx.addSignedInput(outPoint, bUtxo.getScript(), addressBalance.getPrivateKey().getKey(), Transaction.SigHash.ALL, true); - - utxo.setSpent(true); - addressBalance.calculateBalance(); - } - return tx; - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - public Transaction createRawTransactionWithAllUTXOs(ArrayList outputs, double amount) { - Transaction tx = new Transaction(networkParameters); - - for (TransactionOutput output : outputs) { - tx.addOutput(output); - } - - return createRawTransactionWithAllUTXOs(tx, amount); - } - - private ArrayList sortLeastToGreatest() { - ArrayList utxos = new ArrayList<>(); - - for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { - utxos.addAll(addressBalance.getUtxos()); - } - - utxos.sort(Comparator.comparingLong(UTXO::getValue)); - - return utxos; - } - - private ArrayList advancedCoinSorting() { - ArrayList utxos = new ArrayList<>(); - - for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { - utxos.addAll(addressBalance.getUtxos()); - } - - utxos.sort(Comparator.comparingLong(UTXO::getValue)); - - int sizeOfUtxos = utxos.size(); - - if (sizeOfUtxos <= 1) - return utxos; - - ArrayList utxosLowerHalf = new ArrayList<>(utxos.subList(0, ((sizeOfUtxos + 1) / 2))); - ArrayList utxosGreaterHalf = new ArrayList<>(utxos.subList(((sizeOfUtxos + 1) / 2), sizeOfUtxos)); - - ArrayList res = new ArrayList<>(); - - while (utxosLowerHalf.size() > 0 || utxosGreaterHalf.size() > 0) { - if (utxosLowerHalf.size() > 0) { - res.add(utxosLowerHalf.get(0)); - utxosLowerHalf.remove(0); - } - - if (utxosGreaterHalf.size() > 0) { - res.add(utxosGreaterHalf.get(0)); - utxosGreaterHalf.remove(0); - } - } - - return res; - } - - private ArrayList coinSelector(double amount) { - ArrayList utxos = new ArrayList<>(); - double totalBalance = 0.0; - - for (UTXO utxo : advancedCoinSorting()) { - if (totalBalance < amount) { - totalBalance += utxo.getAmount(); - utxos.add(utxo); - } else { - break; - } - } - - if (utxos.size() > 0) - return utxos; - else - return null; - } - - public String formatAmount(double amount) { - DecimalFormat df = new DecimalFormat("#.########"); - return df.format(amount); - } - - public double getTotalBalance() { - double totalBalance = 0.0; - - for (AddressBalance addressBalance : coin.getAddressKeyPairs()) - totalBalance += addressBalance.getBalanceProp(); - - return totalBalance; - } - - public double getSpendBalance(double amount) { - double totalBalance = 0.0; - ArrayList utxos = coinSelector(amount); - - Preconditions.checkNotNull(utxos); - for (UTXO utxo : utxos) - totalBalance += utxo.getAmount(); - - return totalBalance; - } - - public Address getChangeAddress() { - if (coin.getAddressKeyPairs().isEmpty()) { - return null; - } - - return coin.getAddressKeyPairs().get(0).getAddress(); - } - - public AddressBalance generateAddress() { - Wallet wallet = coin.getWallet(); - NetworkParameters params = coin.getNetworkParameters(); - - DeterministicKey key = wallet.freshReceiveKey(); - DumpedPrivateKey privateKey = key.getPrivateKeyEncoded(params); - - Address address = new Address(params, key.getPubKeyHash()) { - public byte[] getHash() { - return new byte[0]; - } - - public Script.ScriptType getOutputScriptType() { - return null; - } - - - public int compareTo(Address o) { - return 0; - } - }; - - return new AddressBalance(address, privateKey); - } - - public AddressBalance generateFromPrivateKey(String privKey) { - NetworkParameters params = coin.getNetworkParameters(); - - ECKey key = DumpedPrivateKey.fromBase58(params, privKey).getKey(); - DumpedPrivateKey privateKey = key.getPrivateKeyEncoded(params); - - Address address = new Address(params, key.getPubKeyHash()) { - public byte[] getHash() { - return new byte[0]; - } - - public Script.ScriptType getOutputScriptType() { - return null; - } - - - public int compareTo(Address o) { - return 0; - } - }; - - return new AddressBalance(address, privateKey); - } - - public void addTransactionToWallet(Transaction transaction) { - coin.addCloudTransaction(new CloudTransaction(transaction)); - } - - public double getBlocknetFeeAmount(BlocknetPeer blocknetPeer) { - return blocknetPeer.getxRouterConfiguration().getFeeMap().get("xrSendTransaction"); - } - - public static Transaction createTransactionSimple(CoinTicker coinTicker, String address, double amount) { - CoinInstance coinInstance = CoinInstance.getInstance(coinTicker); - WalletHelper walletHelper = coinInstance.getWalletHelper(); - NetworkParameters params = coinInstance.getNetworkParameters(); - - double fee = coinInstance.getConfigHelper().getFee(); - double totalSpending = amount + fee; - double totalAvailable = walletHelper.getSpendBalance(totalSpending); - double changeAmt = (totalAvailable - amount) - fee; - Address toAddress = Address.fromBase58(params, address); - Coin sendAmount = Coin.valueOf((long) Math.floor(amount * Coin.COIN.value)); - Coin changeAmount = Coin.valueOf((long) Math.floor(changeAmt * Coin.COIN.value)); - - Transaction tx = new Transaction(params); - - if (isP2SHAddress(coinInstance, address)) { - Script p2shScript = ScriptBuilder.createP2SHOutputScript(toAddress.getHash160()); - tx.addOutput(sendAmount, p2shScript); - } else { - tx.addOutput(sendAmount, toAddress); - } - - if (changeAmount.isPositive()) - tx.addOutput(changeAmount, walletHelper.getChangeAddress()); - - return walletHelper.createRawTransactionWithAllUTXOs(tx, totalAvailable); - } - - public static void setAsSpent(CoinTicker coinTicker, Transaction transaction, boolean setSpent) { - CoinInstance coinInstance = CoinInstance.getInstance(coinTicker); - - for (TransactionInput input : transaction.getInputs()) { - Sha256Hash txid = input.getOutpoint().getHash(); - long vout = input.getOutpoint().getIndex(); - - if (txid == null) - continue; - - for (AddressBalance addressBalance : coinInstance.getAddressKeyPairs()) { - for (UTXO utxo : addressBalance.getUtxos()) { - - if (Sha256Hash.wrap(utxo.getTxid()).equals(txid) && utxo.getVout() == vout) { - utxo.setSpent(setSpent); - } - } - } - } - } - - private static boolean isP2SHAddress(CoinInstance coin, String address) { - byte[] versionAndDataBytes = Base58.decodeChecked(address); - int version = versionAndDataBytes[0] & 0xFF; - - if (coin.getNetworkParameters().getAcceptableAddressCodes().length > 2) { - for (int t : coin.getNetworkParameters().getAcceptableAddressCodes()) { - if (coin.getNetworkParameters().getAddressHeader() != t && t == version) { - return true; - } - } - } - - return coin.getNetworkParameters().getP2SHHeader() == version; - } -} diff --git a/src/main/java/io/xlite/daemon/app/App.java b/src/main/java/io/xlite/daemon/app/App.java new file mode 100644 index 0000000..9e8274c --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/App.java @@ -0,0 +1,280 @@ +package io.xlite.daemon.app; + +import io.xlite.daemon.app.console.ConsoleMenu; +import io.xlite.daemon.app.net.api.JSONRPCController; +import io.xlite.daemon.app.net.api.JSONRPCMasterServer; +import io.xlite.daemon.app.net.api.http.client.EXRServerPool; +import io.xlite.daemon.app.net.api.http.client.HTTPClient; +import io.xlite.daemon.app.util.ConsoleFormatter; +import io.xlite.daemon.app.util.ConfigHelper; +import io.xlite.daemon.app.util.FileFormatter; +import io.xlite.daemon.app.util.LogRotationUtil; +import io.github.cdimascio.dotenv.Dotenv; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.logging.*; + +public class App { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + // DEBUG ENDPOINT + public static String BASE_URL = "https://xliterevp.mywire.org/"; + // "http://xl-dae-prox.airdns.org:42111/"; + // DEBUG ENDPOINT + public static volatile String EXR_ENDPOINT = null; + public static volatile EXRServerPool exrServerPool = null; + public static volatile String BLOCKCHAIN_CONFIGURATION_FILES = null; + public static HTTPClient feeUpdateHttpClient = new HTTPClient(2); + public static HTTPClient heightUpdateHttpClient = new HTTPClient(2); + public static JSONRPCMasterServer masterRPC = JSONRPCController.getMasterServer(); + public static ConsoleMenu console = null; + public static Dotenv dotenv = null; + + public static String getEnv(String key) { + if (dotenv == null) { + try { + dotenv = Dotenv.configure().ignoreIfMissing().load(); + } catch (Exception e) { + LOGGER.finer("[app] No .env file found or failed to load" + e.getMessage()); + } + } + if (dotenv != null) { + String value = dotenv.get(key); + if (value != null) return value; + } + return System.getenv(key); + } + + /** + * Resolves the platform application-data root shared by the XLite apps. + *

Precedence: the {@code XLITE_DATA_HOME} environment variable (when + * non-blank, normalized to an absolute path) over the per-OS default. + * Pure function so the precedence matrix stays unit-testable.

+ * + * @param dataHomeEnv value of {@code XLITE_DATA_HOME} (may be null) + * @param osName value of the {@code os.name} system property + * @param userHome value of the {@code user.home} system property (must be non-blank on mac/Linux) + * @param appDataEnv Windows {@code AppData} environment value (must be non-blank on Windows) + * @return the resolved config-root directory string + * @throws IllegalStateException when no override is set and the platform default is missing + */ + public static String resolveUserConfigDir(String dataHomeEnv, String osName, String userHome, String appDataEnv) { + if (dataHomeEnv != null && !dataHomeEnv.trim().isEmpty()) { + return new File(dataHomeEnv.trim()).getAbsoluteFile().getPath(); + } + String OS = osName.toLowerCase(); + if (OS.contains("win")) { + // Fail fast: returning a null AppData would stringify into a + // bogus relative "null/xlite-daemon/" dir and fork the wallet. + if (appDataEnv == null || appDataEnv.trim().isEmpty()) { + throw new IllegalStateException( + "[app] Cannot resolve config dir on Windows: AppData is not set. " + + "Set XLITE_DATA_HOME to an explicit path."); + } + return appDataEnv; + } else if (OS.contains("mac")) { + if (userHome == null || userHome.trim().isEmpty()) { + throw new IllegalStateException( + "[app] Cannot resolve config dir on macOS: user.home is not set. " + + "Set XLITE_DATA_HOME to an explicit path."); + } + return userHome + File.separator + "Library" + File.separator + "Application Support"; + } + if (userHome == null || userHome.trim().isEmpty()) { + throw new IllegalStateException( + "[app] Cannot resolve config dir: user.home is not set. " + + "Set XLITE_DATA_HOME to an explicit path."); + } + return userHome + File.separator + ".config"; + } + + public static String getUserConfigDir() { + return resolveUserConfigDir(getEnv("XLITE_DATA_HOME"), + System.getProperty("os.name"), + System.getProperty("user.home"), + getEnv("AppData")); + } + + private static Level parseLogLevel(String envValue, Level defaultLevel) { + if (envValue == null || envValue.trim().isEmpty()) { + return defaultLevel; + } + try { + return Level.parse(envValue.trim().toUpperCase()); + } catch (Exception e) { + LOGGER.warning("[app] Invalid log level '" + envValue + "', using default " + defaultLevel); + return defaultLevel; + } + } + + public static void initExrEndpoint() { + if (EXR_ENDPOINT != null) return; + String exrEndpoint = getEnv("EXR_ENDPOINT"); + if (exrEndpoint != null && !exrEndpoint.isEmpty()) { + EXR_ENDPOINT = exrEndpoint; + exrServerPool = new EXRServerPool(EXR_ENDPOINT); + LOGGER.info("[app] EXR mode enabled with " + exrServerPool.getServerCount() + " servers: " + EXR_ENDPOINT); + } + } + + public static void initCoinConfigs(String[] args) { + String flagValue = null; + if (args != null) { + for (int i = 0; i < args.length; i++) { + if ("--blockchain-configuration-files".equals(args[i])) { + if (i + 1 < args.length && !args[i + 1].startsWith("--")) { + flagValue = args[i + 1]; + } else { + LOGGER.warning("[coinconfig] Missing value after --blockchain-configuration-files"); + } + break; + } + } + } + String envValue = getEnv("BLOCKCHAIN_CONFIGURATION_FILES"); + String source; + try { + source = new io.xlite.daemon.app.coinconfig.ConfigSourceResolver(flagValue, envValue).resolve(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "[coinconfig] invalid source, using default: " + e.getMessage()); + source = io.xlite.daemon.app.coinconfig.ConfigSourceResolver.DEFAULT_UPSTREAM; + } + BLOCKCHAIN_CONFIGURATION_FILES = source; + try { + io.xlite.daemon.app.coinconfig.CoinConfigRegistry.loadWithFallback(source, cacheFor(source)); + } catch (Exception e) { + LOGGER.log(Level.WARNING, + "[coinconfig] failed to load from " + source + ": " + e.getMessage() + + " — continuing with no configs; migrated coins will report UNSUPPORTEDCOIN"); + } + } + + /** + * Write-through cache for remote config sources, rooted in the daemon + * data dir. Local checkouts bypass the cache; any failure resolving it + * degrades to uncached (a null cache loads exactly as before). + */ + private static io.xlite.daemon.app.coinconfig.CoinConfigCache cacheFor(String source) { + try { + if (io.xlite.daemon.app.coinconfig.ConfigSourceResolver.isLocalDirectory(source)) + return null; + java.nio.file.Path root = java.nio.file.Paths.get(getUserConfigDir(), + ConfigHelper.DATA_DIR_NAME, "coinconfig-cache"); + return io.xlite.daemon.app.coinconfig.CoinConfigCache.forSource(root, source); + } catch (Exception e) { + LOGGER.log(Level.WARNING, + "[coinconfig] cache unavailable: " + e.getMessage() + " — continuing uncached"); + return null; + } + } + + public static void main(String[] args) { + for (String arg : args) { + if (arg.equals("--version")) { + System.out.println(Version.CLIENT_VERSION); + System.exit(0); + } + if (arg.equals("--help")) { + System.out.println(ConsoleMenu.getHelpText()); + System.exit(0); + } + } + + Level logLevel = parseLogLevel(getEnv("XLITE_DAEMON_LOG_LEVEL"), Level.INFO); + LOGGER.setLevel(logLevel); + LOGGER.setUseParentHandlers(false); + + LogRotationUtil.performLogRotation(); + + Runtime.getRuntime().addShutdownHook(new Thread(App::shutdown)); + + try { + String userHomeDir = getUserConfigDir(); + String logDir = userHomeDir + File.separator + ConfigHelper.DATA_DIR_NAME; + DateTimeFormatter timeStampPattern = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + Handler fileHandler = new FileHandler( + logDir + File.separator + "error-" + timeStampPattern.format(LocalDateTime.now()) + ".log", + 10_000_000, // max file size 10MB + 5, // 5 rotated files + true // append to existing + ); + + fileHandler.setFormatter(new FileFormatter()); + fileHandler.setLevel(Level.INFO); + + LOGGER.addHandler(fileHandler); + + } catch (IOException e) { + LOGGER.warning("[app] Failed to initialize file handler: " + e.getMessage()); + } catch (IllegalStateException e) { + // Fail-fast config errors (unset AppData/user.home, unusable data + // dir) abort startup with a clean message and nonzero status + // instead of an uncaught stack trace. + LOGGER.severe("[app] " + e.getMessage()); + System.exit(1); + } + + ConsoleHandler consoleHandler = new ConsoleHandler(){ + @Override + protected synchronized void setOutputStream(OutputStream out) throws SecurityException { + super.setOutputStream(System.out); + } + }; + consoleHandler.setFormatter(new ConsoleFormatter()); + consoleHandler.setLevel(Level.FINE); + + LOGGER.addHandler(consoleHandler); + + initExrEndpoint(); + initCoinConfigs(args); + + console = new ConsoleMenu(args); + console.init(); + } + + public static void shutdown() { + // First-line trace: proves hook entry even if anything below dies. + LOGGER.info("[shutdown] hook entered"); + try { + shutdownInner(); + } catch (Throwable t) { + LOGGER.log(Level.SEVERE, "[shutdown] hook failed", t); + } + } + + private static void shutdownInner() { + if (masterRPC != null && masterRPC.isAlive()) { + System.out.println("Shutting down..."); + } + + if (console != null) { + console.deinit(); + } + + if (feeUpdateHttpClient != null) { + feeUpdateHttpClient.close(); + } + + if (heightUpdateHttpClient != null) { + heightUpdateHttpClient.close(); + } + + if (exrServerPool != null) { + exrServerPool.close(); + } + + if (masterRPC != null) { + masterRPC.deinit(); + } + + for (Handler handler : LOGGER.getHandlers()) { + LOGGER.removeHandler(handler); + handler.close(); + } + } +} diff --git a/src/main/java/io/cloudchains/app/Version.java b/src/main/java/io/xlite/daemon/app/Version.java similarity index 80% rename from src/main/java/io/cloudchains/app/Version.java rename to src/main/java/io/xlite/daemon/app/Version.java index e9821cd..8db5a2e 100644 --- a/src/main/java/io/cloudchains/app/Version.java +++ b/src/main/java/io/xlite/daemon/app/Version.java @@ -1,8 +1,8 @@ -package io.cloudchains.app; +package io.xlite.daemon.app; public class Version { - private static final String CLIENT_NAME = "CloudChains"; - private static final String CLIENT_PROTOCOL_VERSION = "0.5.14"; + private static final String CLIENT_NAME = "xlite-daemon"; + private static final String CLIENT_PROTOCOL_VERSION = "0.5.15"; public static final String CLIENT_TYPE = "CloudPeer"; public static final String CLIENT_COMMENTS = "SPV"; diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfig.java b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfig.java new file mode 100644 index 0000000..59588c7 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfig.java @@ -0,0 +1,164 @@ +package io.xlite.daemon.app.coinconfig; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable view of one coin's configuration as carried by the + * blockchain-configuration-files repository (manifest entry + the coin's + * section inside its xbridge conf file). + * + *

Values are exposed exactly as parsed; interpretation and range checks + * belong to the parameter layer built on top of this class, not here.

+ */ +public final class CoinConfig { + + private final String ticker; + private final String blockchain; + private final String verId; + private final Map confEntries; + + public CoinConfig(String ticker, String blockchain, String verId, Map confEntries) { + this.ticker = Objects.requireNonNull(ticker, "ticker"); + this.blockchain = Objects.requireNonNull(blockchain, "blockchain"); + this.verId = verId == null ? "" : verId; + this.confEntries = Collections.unmodifiableMap(new LinkedHashMap<>( + Objects.requireNonNull(confEntries, "confEntries"))); + } + + public String getTicker() { + return ticker; + } + + public String getBlockchain() { + return blockchain; + } + + public String getVerId() { + return verId; + } + + /** Raw key/value map of the coin's xbridge-conf section, as parsed. */ + public Map getConfEntries() { + return confEntries; + } + + private String required(String key) { + String v = confEntries.get(key); + if (v == null || v.trim().isEmpty()) + throw new IllegalStateException("[" + ticker + "] missing required config key '" + key + "'"); + return v.trim(); + } + + private long requiredLong(String key) { + try { + return Long.parseLong(required(key)); + } catch (NumberFormatException e) { + throw new IllegalStateException("[" + ticker + "] config key '" + key + + "' is not a number: '" + confEntries.get(key) + "'", e); + } + } + + private int requiredInt(String key) { + try { + return Math.toIntExact(requiredLong(key)); + } catch (ArithmeticException e) { + throw new IllegalStateException("[" + ticker + "] config key '" + key + + "' exceeds the int range: " + confEntries.get(key), e); + } + } + + public int addressPrefix() { + return requiredInt("AddressPrefix"); + } + + public int scriptPrefix() { + return requiredInt("ScriptPrefix"); + } + + public int secretPrefix() { + return requiredInt("SecretPrefix"); + } + + public long coinFactor() { + return requiredLong("COIN"); + } + + public long feePerByte() { + return requiredLong("FeePerByte"); + } + + public long minTxFee() { + return requiredLong("MinTxFee"); + } + + /** + * Optional in current bcf data: every coin carries DustAmount=0 placeholders. + * Absent/blank yields null; a present-but-malformed value throws. + */ + public Long dustAmountOrNull() { + String v = confEntries.get("DustAmount"); + if (v == null || v.trim().isEmpty()) + return null; + try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException e) { + throw new IllegalStateException("[" + ticker + "] config key 'DustAmount" + + "' is not a number: '" + v + "'", e); + } + } + + /** Wallet listen port from the xbridge conf; wallet-conf rpcport may override per user settings. */ + public int port() { + return requiredInt("Port"); + } + + /** + * DTO map for RPC exposure — 11-field (identity + prefixes/fees/port/dust). + * Single source for handler. bcf xbridge-confs DustAmount is canonical source of + * truth (blockchain-configuration-files repo); interim data carries DustAmount=0 + * placeholders meaning unspecified — map 0 to null so GUI fallback (5460) applies + * until bcf is populated with real per-coin values, after which hard-coded + * CompiledCoinSupplement dust becomes removable. + */ + public Map toDtoMap() { + LinkedHashMap m = new LinkedHashMap<>(); + m.put("ticker", ticker); + m.put("blockchain", blockchain); + m.put("verId", verId); + m.put("addressPrefix", addressPrefix()); + m.put("scriptPrefix", scriptPrefix()); + m.put("secretPrefix", secretPrefix()); + m.put("coin", coinFactor()); + m.put("feePerByte", feePerByte()); + m.put("minTxFee", minTxFee()); + m.put("port", port()); + Long dust = dustAmountOrNull(); + if (dust != null && dust == 0L) dust = null; + m.put("dustAmount", dust); + return Collections.unmodifiableMap(m); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CoinConfig)) return false; + CoinConfig that = (CoinConfig) o; + return ticker.equals(that.ticker) + && blockchain.equals(that.blockchain) + && verId.equals(that.verId) + && confEntries.equals(that.confEntries); + } + + @Override + public int hashCode() { + return Objects.hash(ticker, blockchain, verId, confEntries); + } + + @Override + public String toString() { + return "CoinConfig{" + ticker + "/" + verId + ", keys=" + confEntries.keySet() + '}'; + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigCache.java b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigCache.java new file mode 100644 index 0000000..05f128d --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigCache.java @@ -0,0 +1,161 @@ +package io.xlite.daemon.app.coinconfig; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Raw-byte write-through cache for remote coin-config sources. Mirrors the + * remote layout ({@code manifest-latest.json} + {@code xbridge-confs/}) under + * a per-source namespaced directory, so a cache directory doubles as a + * local-directory source for offline fallback. + * + *

Bytes only: parsing, comparison and fetch policy belong to + * {@link CoinConfigSource}. Every method fails open — cache I/O problems + * are logged and the boot continues in-memory; a boot must never fail on + * cache I/O.

+ */ +public final class CoinConfigCache { + + private static final LogManager LOGMANAGER = LogManager.getLogManager(); + private static final Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + /** Sources retained alongside the current one on a source switch. */ + private static final int KEEP_SOURCES = 2; + + private final Path dir; + + private CoinConfigCache(Path dir) { + this.dir = dir; + } + + /** + * Resolves (creating) the cache directory for a source, namespaced by + * the sha16 of the resolved source string so flag/env/default switches + * never read each other's bytes. Retains the current directory plus the + * most recently created other one; older sources are pruned. + */ + public static CoinConfigCache forSource(Path root, String source) { + Path dir = root.resolve("src-" + sha16(source)); + try { + Files.createDirectories(dir.resolve("xbridge-confs")); + pruneOldSources(root, dir); + } catch (Exception e) { + LOGGER.warning("[coinconfig] cache dir unavailable (" + dir + "): " + e.getMessage() + + " — continuing without cache persistence"); + } + return new CoinConfigCache(dir); + } + + /** Cache directory; doubles as a local-directory source for fallback. */ + public Path dir() { + return dir; + } + + /** Cached manifest bytes, or empty on miss/unreadable (fail open). */ + public Optional cachedManifest() { + return readQuietly(dir.resolve("manifest-latest.json")); + } + + /** Persists fresh manifest bytes; I/O failure logs and continues. */ + public void storeManifest(String json) { + writeQuietly(dir.resolve("manifest-latest.json"), json); + } + + /** Cached conf bytes for a validated file name, or empty (fail open). */ + public Optional cachedConf(String fileName) { + return readQuietly(dir.resolve("xbridge-confs").resolve(fileName)); + } + + /** Persists fresh conf bytes; I/O failure logs and continues. */ + public void storeConf(String fileName, String text) { + writeQuietly(dir.resolve("xbridge-confs").resolve(fileName), text); + } + + private static Optional readQuietly(Path p) { + try { + if (!Files.isRegularFile(p)) + return Optional.empty(); + return Optional.of(Files.readString(p, StandardCharsets.UTF_8)); + } catch (Exception e) { + LOGGER.warning("[coinconfig] cache read failed (" + p + "): " + e.getMessage()); + return Optional.empty(); + } + } + + private static void writeQuietly(Path p, String text) { + try { + Files.createDirectories(p.getParent()); + Files.writeString(p, text, StandardCharsets.UTF_8); + } catch (Exception e) { + LOGGER.warning("[coinconfig] cache write failed (" + p + "): " + e.getMessage()); + } + } + + private static void pruneOldSources(Path root, Path current) { + List dirs = new ArrayList<>(); + try (Stream s = Files.list(root)) { + s.filter(Files::isDirectory).forEach(dirs::add); + } catch (IOException e) { + return; + } + dirs.sort(Comparator.comparing((Path d) -> { + try { + return Files.getLastModifiedTime(d); + } catch (IOException e) { + return FileTime.fromMillis(0); + } + }).reversed()); + Set keep = new LinkedHashSet<>(); + keep.add(current); + for (Path d : dirs) { + if (keep.size() >= KEEP_SOURCES) + break; + keep.add(d); + } + for (Path d : dirs) { + if (!keep.contains(d)) + deleteQuietly(d); + } + } + + private static void deleteQuietly(Path d) { + try (Stream walk = Files.walk(d)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + LOGGER.warning("[coinconfig] cache prune failed (" + p + "): " + e.getMessage()); + } + }); + LOGGER.info("[coinconfig] pruned old cache source " + d.getFileName()); + } catch (IOException e) { + LOGGER.warning("[coinconfig] cache prune failed (" + d + "): " + e.getMessage()); + } + } + + private static String sha16(String s) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(s.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(32); + for (byte b : digest) + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + return sb.substring(0, 16); + } catch (Exception e) { + return Integer.toHexString(s.hashCode()); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigGate.java b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigGate.java new file mode 100644 index 0000000..9c1a478 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigGate.java @@ -0,0 +1,50 @@ +package io.xlite.daemon.app.coinconfig; + +import java.util.ArrayList; +import java.util.List; + +/** + * Sanity gate applied to a {@link CoinConfig} before its values may drive + * live address/fee behavior. Violations abort with every problem listed — + * a config that fails here must never silently fall back to anything. + */ +public final class CoinConfigGate { + + public static void validate(CoinConfig cfg) { + List v = new ArrayList<>(); + + checkByteRange("AddressPrefix", cfg.addressPrefix(), v); + checkByteRange("ScriptPrefix", cfg.scriptPrefix(), v); + checkByteRange("SecretPrefix", cfg.secretPrefix(), v); + if (cfg.addressPrefix() == cfg.scriptPrefix()) + v.add("AddressPrefix equals ScriptPrefix (" + cfg.addressPrefix() + ")"); + if (cfg.addressPrefix() == cfg.secretPrefix()) + v.add("AddressPrefix equals SecretPrefix (" + cfg.addressPrefix() + ")"); + if (cfg.scriptPrefix() == cfg.secretPrefix()) + v.add("ScriptPrefix equals SecretPrefix (" + cfg.scriptPrefix() + ")"); + + if (cfg.coinFactor() <= 0) + v.add("COIN must be positive, got " + cfg.coinFactor()); + if (cfg.feePerByte() <= 0) + v.add("FeePerByte must be positive, got " + cfg.feePerByte()); + if (cfg.minTxFee() <= 0) + v.add("MinTxFee must be positive, got " + cfg.minTxFee()); + + int port = cfg.port(); + if (port < 1024 || port > 65_535) + v.add("Port out of range 1024..65535: " + port); + + if (!v.isEmpty()) { + throw new IllegalStateException("[" + + cfg.getTicker() + "] coin config rejected: " + String.join("; ", v)); + } + } + + private static void checkByteRange(String key, int value, List violations) { + if (value < 0 || value > 255) + violations.add(key + " out of byte range 0..255: " + value); + } + + private CoinConfigGate() { + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigRegistry.java b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigRegistry.java new file mode 100644 index 0000000..d568c92 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigRegistry.java @@ -0,0 +1,166 @@ +package io.xlite.daemon.app.coinconfig; + +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +/** + * Process-wide holder for the loaded {@link CoinConfig} map. Initialized once + * at startup from the resolved blockchain-configuration-files source; tests + * may reload with a different source between cases. + */ +public final class CoinConfigRegistry { + + private static final LogManager LOGMANAGER = LogManager.getLogManager(); + private static final Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private static volatile Map loaded = null; + private static volatile String loadedSource = null; + + private CoinConfigRegistry() { + } + + /** + * Load (or reload) all configs from the given source. On success the map + * replaces any prior load — startup calls once; tests may call repeatedly. + * Gate-rejected entries are logged and omitted from the map when the user + * chose warn-and-continue semantics; the registry retains every valid + * ticker. + */ + public static synchronized void load(String source) { + load(source, null); + } + + /** + * Load with offline fallback: when the upstream load fails and the cache + * holds a manifest, serve the stale cache instead of starting coin-less. + * Cold cache (or a cache that itself fails to load) propagates the + * original failure — first-ever boot with no network still fails + * honestly. Staleness is always logged loudly. + */ + public static synchronized void loadWithFallback(String source, CoinConfigCache cache) { + try { + load(source, cache); + return; + } catch (RuntimeException first) { + if (cache == null) + throw first; + String warmed = cache.dir().toString(); + if (!Files.isRegularFile(cache.dir().resolve("manifest-latest.json"))) + throw first; + try { + load(warmed); + } catch (RuntimeException fallbackFailure) { + throw new IllegalStateException("upstream (" + first.getMessage() + + "); cache fallback also failed: " + fallbackFailure.getMessage(), first); + } + LOGGER.log(Level.WARNING, + "[coinconfig] upstream unreachable (" + first.getMessage() + + ") — serving STALE cached configs from " + warmed + + " (" + cacheAge(cache) + ")"); + } + } + + /** + * Load with an optional write-through cache (remote sources). A null + * cache behaves exactly like {@link #load(String)}. + */ + public static synchronized void load(String source, CoinConfigCache cache) { + String resolved = source.replaceAll("/+$", ""); + CoinConfigSource src = new CoinConfigSource(resolved); + if (cache != null) + src.setCache(cache); + Map all = src.loadAll(); + LinkedHashMap filtered = new LinkedHashMap<>(); + int rejected = 0; + for (Map.Entry e : all.entrySet()) { + try { + CoinConfigGate.validate(e.getValue()); + filtered.put(e.getKey(), e.getValue()); + } catch (IllegalStateException ex) { + LOGGER.log(Level.WARNING, + "[coinconfig] rejected [" + e.getKey() + "]: " + ex.getMessage()); + rejected++; + } + } + if (rejected > 0) { + LOGGER.log(Level.WARNING, + "[coinconfig] " + rejected + " ticker(s) rejected by gate; continuing with " + + filtered.size() + " valid"); + } + loaded = Collections.unmodifiableMap(filtered); + loadedSource = resolved; + LOGGER.info("[coinconfig] loaded " + loaded.size() + " ticker(s) from " + resolved); + } + + private static String cacheAge(CoinConfigCache cache) { + try { + FileTime mtime = Files.getLastModifiedTime(cache.dir().resolve("manifest-latest.json")); + long hours = (System.currentTimeMillis() - mtime.toMillis()) / 3_600_000L; + if (hours < 1) + return "under an hour old"; + if (hours < 48) + return hours + "h old"; + return (hours / 24) + "d old"; + } catch (Exception e) { + return "age unknown"; + } + } + + public static boolean isLoaded() { + return loaded != null; + } + + public static String loadedSource() { + return loadedSource; + } + + /** Snapshot of loaded configs. Empty when not loaded. Defensive copy under lock. */ + public static synchronized Map list() { + Map m = loaded; + if (m == null) + return Collections.emptyMap(); + return Collections.unmodifiableMap(new LinkedHashMap<>(m)); + } + + /** Alias for list() — preferred name. */ + public static synchronized Map snapshot() { + return list(); + } + + /** Fail-fast when registry has not been loaded yet. */ + public static CoinConfig get(String ticker) { + Map m = loaded; + if (m == null) + throw new IllegalStateException( + "CoinConfigRegistry not loaded — App.initCoinConfigs() must run before coin init"); + CoinConfig cfg = m.get(ticker); + if (cfg == null) + throw new IllegalStateException( + "[" + ticker + "] not present in loaded coin configs from " + loadedSource); + return cfg; + } + + /** Visible for tests only — public for cross-package default-package TestHelper. */ + public static synchronized void loadForTest(Map cfgs) { + LinkedHashMap filtered = new LinkedHashMap<>(); + for (Map.Entry e : cfgs.entrySet()) { + CoinConfigGate.validate(e.getValue()); + filtered.put(e.getKey(), e.getValue()); + } + loaded = Collections.unmodifiableMap(filtered); + loadedSource = "test-fixture"; + LOGGER.info("[coinconfig] loaded " + loaded.size() + " ticker(s) from test-fixture"); + } + + /** Visible for tests only — public for cross-package tests. */ + public static synchronized void resetForTest() { + loaded = null; + loadedSource = null; + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigSource.java b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigSource.java new file mode 100644 index 0000000..87bd024 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/CoinConfigSource.java @@ -0,0 +1,479 @@ +package io.xlite.daemon.app.coinconfig; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Scanner; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * Loads every coin's {@link CoinConfig} from a blockchain-configuration-files + * source (local directory or base URL), resolving once at daemon start. + * + *

Failure policy: manifest shape and required fields are fail-hard (missing + * ticker/blockchain/xbridge_conf aborts the whole load). Per-entry + * xbridge-conf fetch/parse and missing ticker sections are + * fail-open-warn-and-skip for remote sources (upstream may contain stale + * entries like {@code oasis--v3.0.0.conf}) and fail-hard for local + * directories (developer error). The daemon therefore starts even if a few + * remote entries are stale, but a broken local checkout is caught early.

+ */ +public final class CoinConfigSource { + + private static final LogManager LOGMANAGER = LogManager.getLogManager(); + private static final Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + private static final Pattern XBRIDGE_CONF_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-\\.]+\\.conf$"); + + /** Parallel fetch width for remote conf deltas. */ + private static final int FETCH_POOL_SIZE = 10; + /** Overall cap for the remote conf-fetch phase; past it the boot proceeds with fetched + cached. */ + private static final long FETCH_PHASE_TIMEOUT_SECONDS = 120; + + private final String base; + + /** + * Remote-fetch delegate, defaulting to {@link #fetchRemote(String)}. + * Tests inject a stub; production never replaces it. + */ + private Function remoteFetch = CoinConfigSource::fetchRemote; + + /** + * Write-through cache for remote sources; null disables caching (local + * sources and tests without a cache behave exactly as before). + */ + private CoinConfigCache cache = null; + + /** Test seam: overrides the fetch-phase cap. */ + private long fetchPhaseTimeoutSeconds = FETCH_PHASE_TIMEOUT_SECONDS; + + /** + * @param base the resolved config source: either a local checkout directory + * containing {@code manifest-latest.json} and {@code xbridge-confs/}, + * or a RAW base URL under which those relative paths resolve + * (as produced by ConfigSourceResolver.resolve()). + */ + public CoinConfigSource(String base) { + this.base = base.replaceAll("/+$", ""); + } + + /** Test seam: replaces the HTTP fetch delegate with a stub. */ + void setRemoteFetch(Function remoteFetch) { + this.remoteFetch = remoteFetch; + } + + /** Attaches a write-through cache (remote sources only). */ + void setCache(CoinConfigCache cache) { + this.cache = cache; + } + + /** Test seam: overrides the fetch-phase cap. */ + void setFetchPhaseTimeoutForTest(long seconds) { + this.fetchPhaseTimeoutSeconds = seconds; + } + + /** @return ticker -> config, ordered by manifest appearance */ + public Map loadAll() { + String manifestJson = isLocal() ? readLocalFile(localManifest()) : remoteFetch.apply(manifestUrl()); + JsonArray entries = parseManifestEntries(manifestJson); + // Snapshot the previous cached manifest BEFORE overwriting: the + // compare must run old-vs-fresh, and a failed load must leave the + // last-good cache intact for offline fallback. + Optional prevCachedManifest = + (cache != null && !isLocal()) ? cache.cachedManifest() : Optional.empty(); + RemoteTexts remoteTexts = isLocal() ? null : resolveRemoteTexts(entries, prevCachedManifest); + Map out = new LinkedHashMap<>(); + int index = 0; + int skipped = 0; + int unsupported = 0; + for (JsonElement el : entries) { + index++; + JsonObject entry; + try { + entry = el.getAsJsonObject(); + final String ticker = requiredField(entry, "ticker", index); + if (!isSupportedTicker(ticker)) { + LOGGER.fine("[coinconfig] skipping [" + ticker + "] entry #" + index + + ": ticker not in wallet enum"); + unsupported++; + continue; + } + final String blockchain = requiredField(entry, "blockchain", index); + final String verId = entry.has("ver_id") ? entry.get("ver_id").getAsString() : ""; + final String xbridgeConf = requiredField(entry, "xbridge_conf", index); + if (!XBRIDGE_CONF_PATTERN.matcher(xbridgeConf).matches() || xbridgeConf.contains("..")) { + String msg = "xbridge_conf filename fails validation: " + xbridgeConf; + if (isLocal()) { + throw new IllegalStateException(msg); + } else { + LOGGER.warning("[coinconfig] skipping [" + ticker + "] entry #" + index + ": " + msg); + skipped++; + continue; + } + } + if (out.containsKey(ticker)) { + LOGGER.warning("[coinconfig] duplicate ticker " + ticker + " entry #" + index + + " overwriting previous (keeping last)"); + } + Map> sections; + try { + if (isLocal()) { + sections = safeParseLocal(localXBridgeConf(xbridgeConf)); + } else { + String text = remoteTexts.get(xbridgeConf); + if (text == null) { + // Fetch already failed during the resolve phase and + // was warned + counted there; skip quietly here. + continue; + } + sections = XBridgeConfParser.parse(text); + } + } catch (RuntimeException fe) { + if (isLocal()) { + throw fe; + } else { + LOGGER.warning("[coinconfig] skipping [" + ticker + "] entry #" + index + + " (" + xbridgeConf + "): " + fe.getMessage()); + skipped++; + continue; + } + } + Map section = sections.get(ticker); + if (section == null) { + String msg = "xbridge conf " + xbridgeConf + " has no [" + ticker + "] section"; + if (isLocal()) { + throw new IllegalStateException(msg); + } else { + LOGGER.warning("[coinconfig] skipping [" + ticker + "] entry #" + index + ": " + msg); + skipped++; + continue; + } + } + out.put(ticker, new CoinConfig(ticker, blockchain, verId, section)); + } catch (RuntimeException e) { + // Required-field or JSON shape errors are still hard failures; + // per-entry fetch/section issues for local are re-thrown above + // and will be wrapped here; remote skips are already continued. + if (e.getMessage() != null && e.getMessage().startsWith("manifest entry #")) { + throw e; + } + throw new IllegalStateException("manifest entry #" + index + ": " + e.getMessage(), e); + } + } + if (!isLocal()) { + skipped += remoteTexts.skipped; + } + if (skipped > 0) { + LOGGER.warning("[coinconfig] skipped " + skipped + " manifest entries with missing/unreadable xbridge confs"); + } + if (unsupported > 0) { + LOGGER.info("[coinconfig] skipped " + unsupported + " manifest entries with tickers not in wallet enum"); + } + if (!isLocal()) { + LOGGER.info("[coinconfig] loaded " + out.size() + " ticker(s): " + + remoteTexts.fetched + " fetched, " + remoteTexts.cached + " from cache"); + } + if (out.isEmpty()) { + throw new IllegalStateException("manifest: no loadable entries (" + skipped + + " skipped, " + unsupported + " unsupported)"); + } + if (!isLocal() && cache != null) + cache.storeManifest(manifestJson); + return out; + } + + /** + * Parses a manifest document (bare array or {@code {"contracts": [...]}}). + * Malformed shape and empty lists fail hard — the fresh manifest must be + * valid before it may overwrite the cache. + */ + private static JsonArray parseManifestEntries(String manifestJson) { + JsonArray entries; + try { + JsonElement root = JsonParser.parseString(manifestJson); + if (root.isJsonArray()) { + entries = root.getAsJsonArray(); + } else { + entries = root.getAsJsonObject().getAsJsonArray("contracts"); + if (entries == null) + throw new IllegalStateException("object manifest has no 'contracts' array"); + } + } catch (RuntimeException e) { + throw new IllegalStateException("manifest: " + e.getMessage(), e); + } + if (entries.size() == 0) + throw new IllegalStateException("manifest lists no coins"); + return entries; + } + + /** Resolved conf texts for the remote path, keyed by conf file name. */ + private static final class RemoteTexts { + final Map texts = new LinkedHashMap<>(); + int skipped; + int cached; + int fetched; + + String get(String fileName) { + return texts.get(fileName); + } + } + + /** + * Resolves every eligible remote conf to its text: unchanged entries are + * served from the cache (when attached), the remainder is fetched in + * parallel. Fetch failures are warned + counted here so the main loop + * can skip quietly. Insertion order follows manifest appearance. + * + * @param prevCachedManifest snapshot of the cached manifest taken before + * this load (never the just-fetched one) + */ + private RemoteTexts resolveRemoteTexts(JsonArray entries, Optional prevCachedManifest) { + RemoteTexts plan = new RemoteTexts(); + Map cachedCompare = readCachedCompare(prevCachedManifest); + List toFetch = new ArrayList<>(); + int index = 0; + for (JsonElement el : entries) { + index++; + JsonObject entry = el.getAsJsonObject(); + String ticker = requiredField(entry, "ticker", index); + if (!isSupportedTicker(ticker)) + continue; + String verId = entry.has("ver_id") ? entry.get("ver_id").getAsString() : ""; + String xbridgeConf = requiredField(entry, "xbridge_conf", index); + if (!XBRIDGE_CONF_PATTERN.matcher(xbridgeConf).matches() || xbridgeConf.contains("..")) + continue; + if (plan.texts.containsKey(xbridgeConf) || containsFile(toFetch, xbridgeConf)) + continue; + String want = verId + "\0" + xbridgeConf; + String have = cachedCompare.get(ticker); + if (cache != null && want.equals(have)) { + Optional cachedText = cache.cachedConf(xbridgeConf); + if (cachedText.isPresent() && parsesWithSection(cachedText.get(), ticker)) { + plan.texts.put(xbridgeConf, cachedText.get()); + plan.cached++; + continue; + } + } + toFetch.add(new String[]{xbridgeConf, ticker, String.valueOf(index)}); + } + fetchParallel(toFetch, plan); + return plan; + } + + /** + * Compares the previous cached manifest against fresh entries: ticker to + * ver_id + xbridge_conf. Unreadable cached manifests degrade to empty + * (full refetch), never to failure. + */ + private Map readCachedCompare(Optional prevCachedManifest) { + Map compare = new LinkedHashMap<>(); + if (cache == null || !prevCachedManifest.isPresent()) + return compare; + JsonArray cachedEntries; + try { + cachedEntries = parseManifestEntries(prevCachedManifest.get()); + } catch (RuntimeException e) { + LOGGER.warning("[coinconfig] cached manifest unreadable, refetching all: " + e.getMessage()); + return compare; + } + for (JsonElement el : cachedEntries) { + try { + JsonObject entry = el.getAsJsonObject(); + String ticker = requiredField(entry, "ticker", 0); + String verId = entry.has("ver_id") ? entry.get("ver_id").getAsString() : ""; + String xbridgeConf = requiredField(entry, "xbridge_conf", 0); + compare.put(ticker, verId + "\0" + xbridgeConf); + } catch (RuntimeException e) { + LOGGER.warning("[coinconfig] cached manifest entry unreadable, refetching all: " + e.getMessage()); + return new LinkedHashMap<>(); + } + } + return compare; + } + + private static boolean containsFile(List toFetch, String fileName) { + for (String[] need : toFetch) { + if (need[0].equals(fileName)) + return true; + } + return false; + } + + private static boolean parsesWithSection(String text, String ticker) { + try { + return XBridgeConfParser.parse(text).get(ticker) != null; + } catch (RuntimeException e) { + return false; + } + } + + /** + * Fetches the needed conf files over a bounded pool, overall-capped: + * past the cap the boot proceeds with fetched + cached. Every outcome + * (success, failure, timeout) lands deterministically in the plan. + */ + private void fetchParallel(List toFetch, RemoteTexts plan) { + if (toFetch.isEmpty()) + return; + // Daemon threads: a hung fetch (uninterruptible socket I/O) must + // never delay JVM shutdown past the phase cap. + ExecutorService pool = Executors.newFixedThreadPool(Math.min(FETCH_POOL_SIZE, toFetch.size()), r -> { + Thread t = new Thread(r, "coinconfig-fetch"); + t.setDaemon(true); + return t; + }); + try { + List> tasks = new ArrayList<>(); + for (String[] need : toFetch) { + final String fileName = need[0]; + final String url = xbridgeConfUrl(fileName); + tasks.add(() -> { + String text = remoteFetch.apply(url); + if (cache != null) + cache.storeConf(fileName, text); + return new String[]{fileName, text}; + }); + } + List> futures; + boolean poolInterrupted = false; + try { + futures = pool.invokeAll(tasks, fetchPhaseTimeoutSeconds, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + futures = new ArrayList<>(); + poolInterrupted = true; + } + for (int i = 0; i < toFetch.size(); i++) { + String[] need = toFetch.get(i); + String fileName = need[0]; + String ticker = need[1]; + String entryNo = need[2]; + if (poolInterrupted || i >= futures.size() || futures.get(i).isCancelled()) { + warnFetchSkip(plan, ticker, entryNo, fileName, + poolInterrupted ? "fetch interrupted" : "fetch timed out"); + continue; + } + try { + String[] done = futures.get(i).get(); + plan.texts.put(done[0], done[1]); + plan.fetched++; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + warnFetchSkip(plan, ticker, entryNo, fileName, "fetch interrupted"); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + warnFetchSkip(plan, ticker, entryNo, fileName, cause.getMessage()); + } + } + } finally { + pool.shutdown(); + } + } + + private static void warnFetchSkip(RemoteTexts plan, String ticker, String entryNo, + String fileName, String reason) { + LOGGER.warning("[coinconfig] skipping [" + ticker + "] entry #" + entryNo + + " (" + fileName + "): " + reason); + plan.skipped++; + } + + /** + * True when the wallet can serve this ticker: present in the string map + * and in the supported-coins list. Unknown tickers (null mapping, e.g. + * BCH while its mapping stays commented) and enum members the backend + * does not support are both skipped without any fetch. + */ + private static boolean isSupportedTicker(String ticker) { + CoinTicker t = CoinTickerUtils.stringToTicker(ticker); + return t != null && CoinTicker.coins().contains(t); + } + + private static String requiredField(JsonObject entry, String field, int index) { + JsonElement el = entry.get(field); + if (el == null || el.isJsonNull()) + throw new IllegalStateException("manifest entry #" + index + ": missing '" + field + "'"); + return el.getAsString(); + } + + private boolean isLocal() { + return ConfigSourceResolver.isLocalDirectory(base); + } + + private Path localManifest() { + return Paths.get(base, "manifest-latest.json"); + } + + private Path localXBridgeConf(String fileName) { + return Paths.get(base, "xbridge-confs", fileName); + } + + private String manifestUrl() { + return base + "/manifest-latest.json"; + } + + private String xbridgeConfUrl(String fileName) { + return base + "/xbridge-confs/" + fileName; + } + + private static String readLocalFile(Path p) { + try { + byte[] b = Files.readAllBytes(p); + return new String(b, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Cannot read " + p + ": " + e.getMessage(), e); + } + } + + private static Map> safeParseLocal(Path p) { + try { + return XBridgeConfParser.parseFile(p); + } catch (IOException e) { + throw new IllegalStateException("Cannot read " + p + ": " + e.getMessage(), e); + } + } + + private static String fetchRemote(String url) { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(30_000); + int code = conn.getResponseCode(); + if (code != 200) + throw new IllegalStateException("HTTP " + code + " fetching " + url); + try (InputStream in = conn.getInputStream(); Scanner s = + new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A")) { + return s.hasNext() ? s.next() : ""; + } + } catch (IOException e) { + throw new IllegalStateException("Cannot fetch " + url + ": " + e.getMessage(), e); + } finally { + if (conn != null) + conn.disconnect(); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/CompiledCoinSupplement.java b/src/main/java/io/xlite/daemon/app/coinconfig/CompiledCoinSupplement.java new file mode 100644 index 0000000..2e9a7bf --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/CompiledCoinSupplement.java @@ -0,0 +1,70 @@ +package io.xlite.daemon.app.coinconfig; + +import java.util.Map; + +/** + * The per-coin values a config-driven parameter set needs that the + * blockchain-configuration-files repository does not carry: max supply, + * wire protocol version, network id string and dust threshold. + * + *

Values were lifted verbatim from the legacy per-coin parameter classes + * they replace (BTC from bitcoinj's MainNetParams inheritance). They are + * consensus-era constants that essentially never move — unlike the bcf-carried + * data (prefixes, fees), which is exactly why they live here and not in a + * conf file.

+ */ +public final class CompiledCoinSupplement { + + /** A coin's non-bcf-carried constants. */ + public static final class Supplement { + public final long maxMoneyCoins; + public final long dustSat; + public final int protocolVersion; + public final String id; + public final String chainName; + + public Supplement(long maxMoneyCoins, long dustSat, int protocolVersion, String id, String chainName) { + this.maxMoneyCoins = maxMoneyCoins; + this.dustSat = dustSat; + this.protocolVersion = protocolVersion; + this.id = id; + this.chainName = chainName; + } + } + + private static final long BTC_DUST = 546; // Transaction.MIN_NONDUST_OUTPUT + + private static final Map BY_TICKER = Map.ofEntries( + Map.entry("BTC", new Supplement(21_000_000L, BTC_DUST, 70012, "org.bitcoin.production", "main")), + Map.entry("BCH", new Supplement(21_000_000L, BTC_DUST, 70012, "BCH", "main")), + Map.entry("DASH", new Supplement(22_000_000L, 5460L, 70210, "DASH", "main")), + Map.entry("DGB", new Supplement(2_000_000_000L, 1000L, 70002, "DGB", "main")), + Map.entry("DOGE", new Supplement(2_000_000_000L, BTC_DUST, 70004, "DOGE", "main")), + Map.entry("LTC", new Supplement(84_000_000L, 100_000L, 70015, "LTC", "main")), + Map.entry("PIVX", new Supplement(100_000_000L, BTC_DUST, 70007, "PIVX", "main")), + Map.entry("PKOIN", new Supplement(21_000_000L, BTC_DUST, 70031, "PKOIN", "main")), + Map.entry("RVN", new Supplement(100_000_000L, BTC_DUST, 70026, "RVN", "main")), + Map.entry("SYS", new Supplement(888_000_000L, 5500L, 70227, "SYS", "main")), + Map.entry("UNO", new Supplement(250_000L, BTC_DUST, 70002, "UNO", "main")) + ); + + /** + * @param ticker manifest ticker (case-sensitive, e.g. {@code LTC}) + * @throws IllegalArgumentException for tickers with no compiled entry + */ + public static Supplement forTicker(String ticker) { + Supplement s = BY_TICKER.get(ticker); + if (s == null) + throw new IllegalArgumentException( + "no compiled supplement for ticker '" + ticker + + "' — the coin cannot run without its non-config constants"); + return s; + } + + public static boolean supports(String ticker) { + return BY_TICKER.containsKey(ticker); + } + + private CompiledCoinSupplement() { + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/ConfigSourceResolver.java b/src/main/java/io/xlite/daemon/app/coinconfig/ConfigSourceResolver.java new file mode 100644 index 0000000..c5a580d --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/ConfigSourceResolver.java @@ -0,0 +1,92 @@ +package io.xlite.daemon.app.coinconfig; + +import java.util.Locale; + +/** + * Resolves the blockchain-configuration-files source from, in order of + * precedence: CLI flag {@code --blockchain-configuration-files}, environment + * variable {@code BLOCKCHAIN_CONFIGURATION_FILES}, then the upstream + * blocknetdx default (master branch). + * + *

A source value is either a local filesystem directory that contains + * {@code manifest-latest.json} and {@code xbridge-confs/}, or a base URL under + * which those same relative paths resolve (a raw GitHub URL, or a + * github.com repo URL which is normalized to its raw form).

+ */ +public final class ConfigSourceResolver { + + public static final String FLAG_NAME = "--blockchain-configuration-files"; + public static final String ENV_NAME = "BLOCKCHAIN_CONFIGURATION_FILES"; + public static final String DEFAULT_UPSTREAM = + "https://raw.githubusercontent.com/blocknetdx/blockchain-configuration-files/master"; + + private final String flagValue; + private final String envValue; + + public ConfigSourceResolver(String flagValue, String envValue) { + this.flagValue = normalize(flagValue); + this.envValue = normalize(envValue); + } + + /** @return true when the base is a filesystem directory rather than a URL. */ + public static boolean isLocalDirectory(String base) { + String lower = base.toLowerCase(Locale.ROOT); + return !lower.startsWith("http://") && !lower.startsWith("https://"); + } + + /** @return the resolved source value (never blank); local dir or base URL. */ + public String resolve() { + if (flagValue != null) + return toBase(flagValue); + if (envValue != null) + return toBase(envValue); + return DEFAULT_UPSTREAM; + } + + /** + * Normalizes a github.com web URL ({@code github.com/o/r} or + * {@code .../tree/branch}) to its raw base; other values pass through. + */ + static String toBase(String value) { + String v = normalize(value); + if (v == null) + throw new IllegalArgumentException(FLAG_NAME + ": empty config source"); + String lower = v.toLowerCase(Locale.ROOT); + if (lower.startsWith("https://github.com/") || lower.startsWith("http://github.com/")) { + int hostIdx = lower.indexOf("github.com/") + "github.com/".length(); + String rest = v.substring(hostIdx); + String lowerRest = lower.substring(hostIdx); + while (lowerRest.endsWith("/") || rest.endsWith("/")) { + lowerRest = lowerRest.replaceAll("/+$", ""); + rest = rest.replaceAll("/+$", ""); + } + if (lowerRest.endsWith(".git")) { + lowerRest = lowerRest.substring(0, lowerRest.length() - ".git".length()); + rest = rest.substring(0, rest.length() - ".git".length()); + } + String branch = "master"; + for (String marker : new String[]{"/tree/", "/blob/"}) { + int idx = lowerRest.indexOf(marker); + if (idx >= 0) { + branch = rest.substring(idx + marker.length()); + rest = rest.substring(0, idx); + if (branch.isEmpty() || branch.contains("/")) + throw new IllegalArgumentException( + "unsupported github URL form '" + value + + "' (expected github.com//[.git][/tree/])"); + break; + } + } + String scheme = lower.startsWith("http://") ? "http://" : "https://"; + return scheme + "raw.githubusercontent.com/" + rest + "/" + branch; + } + return v.replaceAll("/+$", ""); + } + + static String normalize(String v) { + if (v == null) + return null; + String t = v.trim(); + return t.isEmpty() ? null : t; + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/ConfigurableNetworkParameters.java b/src/main/java/io/xlite/daemon/app/coinconfig/ConfigurableNetworkParameters.java new file mode 100644 index 0000000..1b456f3 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/ConfigurableNetworkParameters.java @@ -0,0 +1,152 @@ +package io.xlite.daemon.app.coinconfig; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +import java.util.Locale; +import java.util.Objects; + +/** + * Network parameters for one coin, driven entirely by its + * {@link CoinConfig} (blockchain-configuration-files data) plus the small + * {@link CompiledCoinSupplement} constants bcf does not carry. + * + *

Replaces the per-coin hardcoded parameter classes. Behavior is + * byte-compatible with those classes except where the loaded config + * intentionally differs (e.g. DGB ScriptPrefix 63, RVN FeePerByte 3000).

+ * + *

Deliberately preserved status quo: the segwit HRP stays unset — bech32 + * address validation keeps failing for these coins (segwit is unsupported); + * difficulty/interval/subsidy overrides remain inert stubs (no BlockChain is + * ever constructed in this daemon); monetary format and URI scheme are dead + * stubs returning ticker-derived values; {@code getPort()} is likewise not + * overridden — only BTC's legacy class carried a real p2p port (8333 via + * MainNetParams); all other legacy classes left the same base default this + * class returns. Safe because nothing in this daemon reads a migrated coin's + * params-port: the local RPC port comes from {@code CoinInstance} and the + * only params-port consumer ({@code BlocknetPeerGroup}) is BLOCK-only.

+ */ +public final class ConfigurableNetworkParameters extends NetworkParameters implements HasFeeParams { + + private final CoinConfig config; + private final CompiledCoinSupplement.Supplement supplement; + private final Coin maxMoney; + private final Coin minNonDustOutput; + + public static ConfigurableNetworkParameters from(CoinConfig config) { + return new ConfigurableNetworkParameters(config, + CompiledCoinSupplement.forTicker(config.getTicker())); + } + + public ConfigurableNetworkParameters(CoinConfig config, CompiledCoinSupplement.Supplement supplement) { + this.config = Objects.requireNonNull(config, "config"); + this.supplement = Objects.requireNonNull(supplement, "supplement"); + CoinConfigGate.validate(config); + this.maxMoney = Coin.valueOf(Math.multiplyExact(supplement.maxMoneyCoins, Coin.COIN.value)); + this.minNonDustOutput = Coin.valueOf(supplement.dustSat); + } + + public CoinConfig getConfig() { + return config; + } + + /** + * True once OUR constructor body has run. The NetworkParameters base + * constructor calls several of these getters virtually before that, so + * every accessor degrades to a neutral value until then. + */ + private boolean ready() { + return config != null && supplement != null; + } + + @Override + public String getPaymentProtocolId() { + return ready() ? supplement.chainName : "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) + throws VerificationException, BlockStoreException { + // no-op: this daemon never verifies block headers (EXR HTTP client) + } + + @Override + public Coin getMaxMoney() { + return ready() ? maxMoney : NetworkParameters.MAX_MONEY; + } + + @Override + public Coin getMinNonDustOutput() { + return ready() ? minNonDustOutput : Coin.ZERO; + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return ready() ? new MonetaryFormat().code(0, config.getTicker()) : new MonetaryFormat(); + } + + @Override + public String getUriScheme() { + return ready() ? config.getTicker().toLowerCase(Locale.ROOT) + ":" : ""; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return ready() ? supplement.protocolVersion + : ProtocolVersion.CURRENT.getBitcoinProtocolVersion(); + } + + @Override + public int getAddressHeader() { + return ready() ? config.addressPrefix() : 0; + } + + @Override + public int getP2SHHeader() { + return ready() ? config.scriptPrefix() : 0; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return ready() ? config.secretPrefix() : 0; + } + + @Override + public int getInterval() { + return 210_000; // inert: no BlockChain exists in this daemon + } + + @Override + public int getSubsidyDecreaseBlockCount() { + return 210_000; // inert: see getInterval() + } + + @Override + public String getId() { + // must be non-null and != ID_UNITTESTNET (wallet creation checks it) + return ready() ? supplement.id : ""; + } + + @Override + public long getFeePerByte() { + return ready() ? config.feePerByte() : 1L; + } + + @Override + public long getMinTxFee() { + return ready() ? config.minTxFee() : 1000L; + } +} diff --git a/src/main/java/io/xlite/daemon/app/coinconfig/XBridgeConfParser.java b/src/main/java/io/xlite/daemon/app/coinconfig/XBridgeConfParser.java new file mode 100644 index 0000000..6cc69de --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/coinconfig/XBridgeConfParser.java @@ -0,0 +1,62 @@ +package io.xlite.daemon.app.coinconfig; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parses one xbridge conf file into per-section key/value maps. + * + *

Format (as shipped by blockchain-configuration-files): sections opened by + * a {@code [TICKER]} line, followed by {@code Key=Value} lines. Blank lines and + * {@code #}/{@code ;} comments are skipped. Keys keep their original case; + * lookups are case-sensitive, matching the files as shipped.

+ * + *

Parsing fails hard: a UTF-8 BOM is tolerated and stripped, but any other + * stray or malformed line throws rather than being dropped silently. A section + * header must be exactly {@code [NAME]} (no nested brackets). Duplicates are + * deterministic: a repeated key keeps its LAST value; a repeated section name + * merges into the first occurrence.

+ */ +public final class XBridgeConfParser { + + public static Map> parse(String contents) { + if (contents.startsWith("\uFEFF")) + contents = contents.substring(1); + Map> sections = new LinkedHashMap<>(); + String current = null; + int lineNo = 0; + for (String rawLine : contents.split("\\r?\\n", -1)) { + lineNo++; + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || line.startsWith(";")) + continue; + if (line.startsWith("[") && line.endsWith("]")) { + current = line.substring(1, line.length() - 1).trim(); + if (current.isEmpty() || current.indexOf('[') >= 0 || current.indexOf(']') >= 0) + throw new IllegalStateException("Malformed section header at line " + lineNo + + ": '" + rawLine + "'"); + sections.putIfAbsent(current, new LinkedHashMap<>()); + continue; + } + if (current == null) + throw new IllegalStateException( + "Malformed line " + lineNo + " outside any section: '" + rawLine + "'"); + int eq = line.indexOf('='); + if (eq <= 0) + throw new IllegalStateException( + "Malformed line " + lineNo + " in [" + current + "]: '" + rawLine + "'"); + String key = line.substring(0, eq).trim(); + String value = line.substring(eq + 1).trim(); + sections.get(current).put(key, value); + } + return sections; + } + + public static Map> parseFile(Path file) throws IOException { + return parse(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/io/xlite/daemon/app/console/ArgMenu.java b/src/main/java/io/xlite/daemon/app/console/ArgMenu.java new file mode 100644 index 0000000..b026cae --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/console/ArgMenu.java @@ -0,0 +1,100 @@ +package io.xlite.daemon.app.console; + +import io.xlite.daemon.app.crypto.KeyHandler; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; + +import java.util.Arrays; + +public class ArgMenu { + private String[] arguments; + + public ArgMenu(String[] args) { + this.arguments = args; + } + + public void init() { + int selection = 2; + char[] password = null; + + System.out.println("-------------------------"); + System.out.println("Help: "); + System.out.println("Create new wallet: --new-wallet (password)"); + System.out.println("Decrypt wallet: --decrypt-wallet (password)"); + + if (arguments.length == 0) { + System.out.println("No arguments given."); + System.exit(0); + } else { + if (arguments.length == 1) { + password = arguments[0].toCharArray(); + } else if (arguments.length == 2 && arguments[0].equals("--new-wallet")) { + selection = 1; + password = arguments[1].toCharArray(); + } else if (arguments.length == 2 && arguments[0].equals("--decrypt-wallet")) { + password = arguments[1].toCharArray(); + } + } + + try { + if (password == null) { + System.out.println("Unrecognized arguments. Use --new-wallet or --decrypt-wallet ."); + return; + } + switch (selection) { + case 1: { + if (KeyHandler.existsBaseECKeyFromLocal()) { + System.out.println("Key already exists"); + return; + } + + int strength = KeyHandler.calculatePasswordStrength(password); + + if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { + System.out.println("Bad password."); + return; + } + + completeLogin(password, null); + break; + } + case 2: { + int strength = KeyHandler.calculatePasswordStrength(password); + + if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { + System.out.println("Bad password."); + return; + } + + completeLogin(password, null); + break; + } + case 3: { + System.out.println("Exiting..."); + System.exit(0); + } + break; + default: + throw new IllegalStateException("Unexpected value: " + selection); + } + } finally { + if (password != null) Arrays.fill(password, '\0'); + } + } + + private void completeLogin(char[] password, String userMnemonic) { + CoinInstance.CoinError coinError = CoinInstance.getInstance(CoinTicker.BLOCKNET).init(password, userMnemonic, false); + if (coinError != null) { + System.out.println("[master] Error(" + coinError.getCode().name() + "): " + coinError.getMessage()); + System.exit(0); + } + + for (CoinTicker cointicker : CoinTicker.coins()) { + if (cointicker == CoinTicker.BLOCKNET || cointicker == CoinTicker.BITCOIN) + continue; + coinError = CoinInstance.getInstance(cointicker).init(password, userMnemonic, false); + if (coinError != null) + System.out.println("[" + cointicker.name() + "] Error(" + coinError.getCode().name() + "): " + coinError.getMessage()); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/console/ConsoleMenu.java b/src/main/java/io/xlite/daemon/app/console/ConsoleMenu.java new file mode 100644 index 0000000..4dc8f26 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/console/ConsoleMenu.java @@ -0,0 +1,603 @@ +package io.xlite.daemon.app.console; + +import io.xlite.daemon.app.App; +import io.xlite.daemon.app.Version; +import io.xlite.daemon.app.crypto.KeyHandler; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.net.api.http.client.EXRServerPool; +import io.xlite.daemon.app.util.ConfigHelper; +import io.xlite.daemon.app.util.background.BackgroundTimerThread; + +import java.io.Console; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Scanner; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +public class ConsoleMenu { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + private String[] arguments; + private BackgroundTimerThread backgroundTimerThread = null; + private boolean xliteRPC = false; + + public ConsoleMenu(String[] args) { + this.arguments = args; + // Do NOT set the logger level here — App owns logging configuration + // (XLITE_DAEMON_LOG_LEVEL); clobbering it in this constructor made all + // FINE/FINER diagnostics unreachable. + } + + public void logBadPassword(String msg) { + if (msg == null || msg.isEmpty()) + msg = "Bad password"; + LOGGER.info("[master] Error(" + CoinInstance.CoinError.CoinErrorCode.BADPASSWORD.name() + "): " + msg); + } + + public void logBadMnemonic() { + LOGGER.info("[master] Error(" + CoinInstance.CoinError.CoinErrorCode.BADMNEMONIC.name() + "): Bad mnemonic"); + } + + public void logBadChangePass(String msg) { + LOGGER.info("[master] Error(" + CoinInstance.CoinError.CoinErrorCode.CHANGEPASSWORDFAILED.name() + "): " + msg); + } + + public void init() { + int selection; + String newWalletStr = ""; + Scanner input = new Scanner(System.in); + + if (KeyHandler.existsBaseECKeyFromLocal()) { + newWalletStr = "- Disabled. Wallet already exists."; + } + + if (arguments.length > 0) { + for (int i = 0; i < arguments.length; i++) { + String argument = arguments[i]; + + switch (argument) { + case "--enablerpcandconfigure": + autoGenerateRPCConfig(); + System.exit(0); + case "--development-endpoint": { + if (i + 1 < arguments.length) { + String customEndpoint = arguments[i + 1]; + if (customEndpoint.startsWith("--")) { + LOGGER.warning("Invalid endpoint: " + customEndpoint); + break; + } + App.BASE_URL = customEndpoint; + i++; + } else { + String envEndpoint = App.getEnv("BASE_URL"); + if (envEndpoint != null && !envEndpoint.isEmpty()) { + App.BASE_URL = envEndpoint; + } else { + LOGGER.warning("Missing custom endpoint after '--development-endpoint'"); + } + } + break; + } + case "--exr-endpoint": { + if (i + 1 < arguments.length) { + String exrEndpoint = arguments[i + 1]; + if (exrEndpoint.startsWith("--")) { + LOGGER.warning("Invalid endpoint: " + exrEndpoint); + break; + } + App.EXR_ENDPOINT = exrEndpoint; + App.exrServerPool = new EXRServerPool(App.EXR_ENDPOINT); + LOGGER.info("[console] EXR mode enabled with " + App.exrServerPool.getServerCount() + " servers: " + App.EXR_ENDPOINT); + spawnExrCapabilityProbe(); + i++; + } else { + String envExrEndpoint = App.getEnv("EXR_ENDPOINT"); + if (envExrEndpoint != null && !envExrEndpoint.isEmpty()) { + App.EXR_ENDPOINT = envExrEndpoint; + App.exrServerPool = new EXRServerPool(App.EXR_ENDPOINT); + LOGGER.info("[console] EXR mode enabled with " + App.exrServerPool.getServerCount() + " servers: " + App.EXR_ENDPOINT); + spawnExrCapabilityProbe(); + } else { + LOGGER.warning("Missing EXR endpoint after '--exr-endpoint'"); + } + } + break; + } + case "--blockchain-configuration-files": { + // Flag already handled in App.initCoinConfigs(); just consume the value here + // to avoid falling through to the interactive menu. + if (i + 1 < arguments.length && !arguments[i + 1].startsWith("--")) { + i++; + } else { + String envVal = App.getEnv("BLOCKCHAIN_CONFIGURATION_FILES"); + if (envVal == null || envVal.isEmpty()) { + LOGGER.warning("Missing value after '--blockchain-configuration-files'"); + } + } + break; + } + case "--version": + LOGGER.info(Version.CLIENT_VERSION); + System.exit(0); + break; + case "--createdefaultwallet": { + if (KeyHandler.existsBaseECKeyFromLocal()) { + LOGGER.info("Wallet already exists"); + System.exit(0); + } + + char[] password = readPasswordChars(input, arguments, i + 1, "", "WALLET_PASSWORD"); + try { + int strength = KeyHandler.calculatePasswordStrength(password); + if (strength < 9) { + logBadPassword(null); + System.exit(1); + } + + List mnemonic = KeyHandler.getBaseSeed(password); + if (mnemonic == null) { + logBadPassword(null); + System.exit(1); + } + } finally { + Arrays.fill(password, '\0'); + } + + System.exit(0); + } + case "--createwalletmnemonic": { + if (KeyHandler.existsBaseECKeyFromLocal()) { + LOGGER.info("Wallet already exists"); + System.exit(0); + } + + char[] password = readPasswordChars(input, arguments, i + 1, "", "WALLET_PASSWORD"); + String mnemonic = readPassword(input, arguments, i + 2, "Mnemonic:\n", "WALLET_MNEMONIC").trim(); + try { + int strength = KeyHandler.calculatePasswordStrength(password); + if (strength < 9) { + logBadPassword(null); + System.exit(1); + } + if (mnemonic.isEmpty()) { + logBadMnemonic(); + System.exit(1); + } + + if (!KeyHandler.importFromMnemonic(Arrays.asList(mnemonic.split(" ")), password)) { + logBadMnemonic(); + System.exit(1); + } + } finally { + Arrays.fill(password, '\0'); + } + + System.exit(0); + } + case "--xliterpc": { + xliteRPC = true; + break; + } + case "--password": { + char[] password = readPasswordChars(input, arguments, i + 1, "", "WALLET_PASSWORD"); + try { + int strength = KeyHandler.calculatePasswordStrength(password); + + if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { + LOGGER.info("Bad password."); + System.exit(1); + } + + completeLogin(password, null, false); + } finally { + Arrays.fill(password, '\0'); + } + + return; + } + case "--getmnemonic": { + char[] password = readPasswordChars(input, arguments, i + 1, "", "WALLET_PASSWORD"); + try { + if (!KeyHandler.existsBaseECKeyFromLocal()) { + LOGGER.info("No wallet found."); + System.exit(1); + } + + String mnemonic = CoinInstance.getMnemonicForPw(password); + if (mnemonic == null || mnemonic.isEmpty()) { + // Wrong password (or unusable wallet) must not + // masquerade as success with an empty phrase. + // Wipe BEFORE exiting — System.exit does not + // unwind, so a finally block would be skipped. + LOGGER.severe("Error(BADPASSWORD): could not retrieve mnemonic for the supplied password"); + Arrays.fill(password, '\0'); + System.exit(4); + } + System.out.println(mnemonic); + } finally { + Arrays.fill(password, '\0'); + } + System.exit(0); + } + case "--changepassword": { + if (!KeyHandler.existsBaseECKeyFromLocal()) { + logBadChangePass("Wallet not found"); + System.exit(1); + } + + char[] currentPassword = readPasswordChars(input, arguments, i + 1, "", "WALLET_PASSWORD"); + char[] newPassword = readPasswordChars(input, arguments, i + 2, "", null); + try { + if (currentPassword.length == 0 || newPassword.length == 0) { + LOGGER.info("Password cannot be empty"); + System.exit(1); + } + if (Arrays.equals(currentPassword, newPassword)) { + LOGGER.info("New password must be different from old password"); + System.exit(1); + } + + int strength = KeyHandler.calculatePasswordStrength(newPassword); + if (strength < 9) { + LOGGER.info("Unable to change the password: New password is not strong enough"); + System.exit(1); + } + + CoinInstance.CoinError err = CoinInstance.changePassword(currentPassword, newPassword); + if (err != null) + logBadChangePass(err.getMessage()); + else + LOGGER.info("Wallet password changed successfully"); + } finally { + Arrays.fill(currentPassword, '\0'); + Arrays.fill(newPassword, '\0'); + } + + System.exit(0); + } + case "--help": + displayHelp(); + System.exit(0); + } + } + } + + String mnemonicImport = App.getEnv("WALLET_MNEMONIC"); + if (mnemonicImport != null && !mnemonicImport.isEmpty()) { + char[] mnemonicChars = mnemonicImport.toCharArray(); + try { + completeLogin(mnemonicChars, null, true); + } finally { + Arrays.fill(mnemonicChars, '\0'); + } + return; + } else { + String passwordEnv = App.getEnv("WALLET_PASSWORD"); + if (passwordEnv != null && !passwordEnv.isEmpty()) { + char[] password = passwordEnv.toCharArray(); + try { + int strength = KeyHandler.calculatePasswordStrength(password); + + if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { + LOGGER.info("Bad password."); + return; + } + + completeLogin(password, null, false); + } finally { + Arrays.fill(password, '\0'); + } + return; + } + } + + while (true) { + LOGGER.info("-------------------------"); + LOGGER.info("1 - Create new wallet " + newWalletStr); + LOGGER.info("2 - Decrypt wallet"); + LOGGER.info("3 - Import from mnemonic"); + LOGGER.info("4 - Quit"); + + LOGGER.info("Selection: "); + selection = input.nextInt(); + input.nextLine(); + + switch (selection) { + case 1: { + if (KeyHandler.existsBaseECKeyFromLocal()) { + LOGGER.info("Key already exists"); + return; + } + + Console console = System.console(); + char[] password; + if (console != null) { + password = console.readPassword("Enter new password: "); + } else { + LOGGER.info("Enter new password: "); + password = input.next().toCharArray(); + } + try { + int strength = KeyHandler.calculatePasswordStrength(password); + + if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { + LOGGER.info("Bad password."); + return; + } + completeLogin(password, null, false); + } finally { + Arrays.fill(password, '\0'); + } + return; + } + case 2: { + LOGGER.info("Enter password: "); + Console console = System.console(); + char[] password; + if (console != null) { + password = console.readPassword(); + } else { + LOGGER.warning("Console not available, using Scanner fallback"); + password = readPasswordChars(input, null, 0, "", null); + } + try { + int strength = KeyHandler.calculatePasswordStrength(password); + + if (!KeyHandler.existsBaseECKeyFromLocal() && strength < 9) { + LOGGER.info("Bad password."); + return; + } + completeLogin(password, null, false); + } finally { + Arrays.fill(password, '\0'); + } + return; + } + case 3: { + LOGGER.info("Enter mnemonic: "); + String mnemonicInput = input.nextLine().trim(); + + char[] mnemonicChars = mnemonicInput.toCharArray(); + try { + completeLogin(mnemonicChars, null, true); + } finally { + Arrays.fill(mnemonicChars, '\0'); + } + return; + } + case 4: { + LOGGER.info("Exiting..."); + System.exit(0); + } + default: { + LOGGER.info("Unknown Option."); + } + } + } + } + + public void deinit() { + if (backgroundTimerThread != null) + backgroundTimerThread.stop(); + for (CoinTicker cointicker : CoinTicker.coins()) { + CoinInstance instance = CoinInstance.getInstance(cointicker); + if (instance != null) + instance.deinit(); + } + } + + private void completeLogin(char[] password, String userMnemonic, boolean isMnemonic) { + if (password == null && userMnemonic == null) { + logBadPassword(null); + System.exit(5); + } + + long startTime = System.currentTimeMillis(); + + CoinInstance.CoinError coinError = CoinInstance.getInstance(CoinTicker.BLOCKNET).init(password, null, isMnemonic, xliteRPC); + if (coinError != null) { + String msg = "[master] Error(" + coinError.getCode().name() + "): " + coinError.getMessage(); + LOGGER.severe(msg); + // Auth/init failure must not exit 0 — scripts and the GUI treat + // exit code 0 as success. Wipe the credential before exiting + // (System.exit does not unwind caller finally blocks). + if (password != null) + Arrays.fill(password, '\0'); + System.exit(5); + } + + List otherCoins = new ArrayList<>(); + for (CoinTicker cointicker : CoinTicker.coins()) { + if (cointicker != CoinTicker.BLOCKNET) { + otherCoins.add(cointicker); + } + } + + initializeCoinsConcurrently(otherCoins, password, null, isMnemonic, xliteRPC); + + long endTime = System.currentTimeMillis(); + long totalTime = endTime - startTime; + LOGGER.info("[coin] Concurrent coins initialization completed in " + totalTime + " ms"); + + App.masterRPC.start(); + backgroundTimerThread = new BackgroundTimerThread(); + // Sole deliberate non-daemon anchor: this named thread is what + // keeps the JVM alive while the wallet runs. Every long-lived + // pool in the process (RPC, servers, probes, timer scheduler) is + // daemonized; the coin-init pool is short-lived, daemon-factory, + // and always drained in its finally. deinit()/stop() releases + // this anchor on shutdown. + Thread timerAnchor = new Thread(backgroundTimerThread, + "background-timer-anchor"); + timerAnchor.start(); + if (App.exrServerPool != null) { + App.exrServerPool.probeAllCapabilities(); + } + } + + private void initializeCoinsConcurrently(List coinTickers, char[] password, + String userMnemonic, boolean isMnemonic, boolean xliteRPC) { + if (coinTickers.isEmpty()) { + return; + } + + int threadCount = Math.min(coinTickers.size(), 8); + // Daemon factory: init tasks are bounded by their futures and the + // pool is drained in finally — it must never become a second + // liveness anchor if a task survives shutdownNow's interrupt. + ExecutorService executor = Executors.newFixedThreadPool(threadCount, r -> { + Thread t = new Thread(r, "coin-init"); + t.setDaemon(true); + return t; + }); + + try { + List enabledCoins = coinTickers.stream() + .filter(ticker -> ticker == CoinTicker.BLOCKNET || CoinInstance.getInstance(ticker) != null) + .collect(Collectors.toList()); + + CompletableFuture[] futures = enabledCoins.stream() + .map(coinTicker -> CompletableFuture.runAsync(() -> { + try { + LOGGER.fine("[coin] Initializing " + CoinTickerUtils.tickerToString(coinTicker) + " concurrently"); + CoinInstance.CoinError coinError = CoinInstance.getInstance(coinTicker) + .init(password, userMnemonic, isMnemonic, xliteRPC); + if (coinError != null) { + LOGGER.warning("[" + coinTicker.name() + "] Error(" + + coinError.getCode().name() + "): " + coinError.getMessage()); + } + } catch (Exception e) { + LOGGER.severe("Failed to initialize " + coinTicker.name() + ", " + e.getMessage()); + } + }, executor)) + .toArray(CompletableFuture[]::new); + + CompletableFuture.allOf(futures).join(); + + + } finally { + executor.shutdown(); + try { + if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + + /** + * Deferred best-effort capability probe for a freshly configured EXR + * pool. Daemon thread: a hung endpoint must never hold the JVM. + */ + private void spawnExrCapabilityProbe() { + Thread prober = new Thread(() -> { + try { + Thread.sleep(1000); + App.exrServerPool.probeAllCapabilities(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, "EXR-Capability-Prober"); + prober.setDaemon(true); + prober.start(); + } + + private void autoGenerateRPCConfig() { + for (CoinTicker cointicker : CoinTicker.coins()) { + ConfigHelper configHelper = new ConfigHelper(CoinTickerUtils.tickerToString(cointicker)); + + configHelper.setRpcUsername(generateRandomString(24)); + configHelper.setRpcPassword(generateRandomString(32)); + configHelper.setRpcEnabled(true); + configHelper.writeConfig(); + } + ConfigHelper masterConf = new ConfigHelper("master"); + masterConf.setRpcUsername(generateRandomString(24)); + masterConf.setRpcPassword(generateRandomString(32)); + masterConf.setRpcEnabled(true); + masterConf.writeConfig(); + } + + private String generateRandomString(int length) { + SecureRandom secureRandom = new SecureRandom(); + + byte[] token = new byte[length]; + secureRandom.nextBytes(token); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(token); + } + + private String readPassword(Scanner input, String[] args, int argPos, String msg, String envVar) { + if (msg.isEmpty()) + msg = "Password:\n"; + if (args == null || args.length <= argPos || args[argPos].contains("--")) { + if (envVar != null) { + String envVal = App.getEnv(envVar); + if (envVal != null && !envVal.isEmpty()) + return envVal; + } + System.out.println(msg); + return input.nextLine(); + } + return args[argPos]; + } + + /** + * Reads the password as a char[] from args, environment variable, or stdin. + * Caller MUST zero-fill the returned array after use. + */ + private char[] readPasswordChars(Scanner input, String[] args, int argPos, String msg, String envVar) { + if (msg.isEmpty()) + msg = "Password:\n"; + if (args == null || args.length <= argPos || args[argPos].contains("--")) { + if (envVar != null) { + String envVal = App.getEnv(envVar); + if (envVal != null && !envVal.isEmpty()) + return envVal.toCharArray(); + } + System.out.println(msg); + return input.nextLine().toCharArray(); + } + return args[argPos].toCharArray(); + } + + private static void displayHelp() { + System.out.print(getHelpText()); + } + + public static String getHelpText() { + return "Usage: xlite-daemon [options]\n" + + "Options:\n" + + " --enablerpcandconfigure Enable and configure RPC\n" + + " --development-endpoint Set a custom development endpoint\n" + + " Example: --development-endpoint \n" + + " --exr-endpoint Set EXR endpoint for EXR server\n" + + " Example: --exr-endpoint \n" + + " --blockchain-configuration-files Set blockchain-configuration-files source\n" + + " Example: --blockchain-configuration-files \n" + + " --version Display the version\n" + + " --createdefaultwallet Create a default wallet\n" + + " --createwalletmnemonic Create a wallet with a mnemonic\n" + + " --xliterpc Increment RPC port by 1\n" + + " --password Set password without prompt\n" + + " Example: --password \n" + + " --getmnemonic Retrieve mnemonic for a password\n" + + " Example: --getmnemonic \n" + + " --changepassword Change wallet password\n" + + " Example: --changepassword \n"; + } +} diff --git a/src/main/java/io/xlite/daemon/app/crypto/KeyHandler.java b/src/main/java/io/xlite/daemon/app/crypto/KeyHandler.java new file mode 100644 index 0000000..0c72246 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/crypto/KeyHandler.java @@ -0,0 +1,644 @@ +package io.xlite.daemon.app.crypto; + +import com.google.common.base.Joiner; +import com.subgraph.orchid.encoders.Base64; +import io.xlite.daemon.app.util.ConfigHelper; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.crypto.MnemonicCode; +import org.bitcoinj.crypto.MnemonicException; +import org.bitcoinj.wallet.DeterministicSeed; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +/** + * Handles BIP39 mnemonic seed storage, AES-CBC encryption/decryption, + * and automatic migration from the legacy AES-ECB (SHA-1) format. + * + *

Passphrase handling: all public methods accept {@code char[]} so callers + * can zero the array immediately after use. Passing a {@code String} literal is + * intentionally unsupported — {@code String} is immutable and cannot be wiped. + */ +public class KeyHandler { + + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + // ------------------------------------------------------------------------- + // Version constants + // ------------------------------------------------------------------------- + private static final int VERSION_1_SHA1 = 1; // Legacy: AES-ECB, PBKDF2-SHA-1, 16k iters + private static final int VERSION_2_SHA256 = 2; // Current: AES-CBC, PBKDF2-SHA-256, 100k iters + private static final int CURRENT_VERSION = VERSION_2_SHA256; + private static final String VERSION_HEADER = "VERSION:"; + + // ------------------------------------------------------------------------- + // Crypto parameters + // ------------------------------------------------------------------------- + private static final int PBKDF2_ITERATIONS_SHA256 = 100_000; + private static final int PBKDF2_ITERATIONS_SHA1 = 16_384; // legacy only + private static final int KEY_LENGTH = 256; // AES key bits + private static final int SALT_LENGTH = 20; // bytes + private static final int IV_LENGTH = 16; // bytes, AES block size + + private static final String PBKDF2_SHA256 = "PBKDF2WithHmacSHA256"; + private static final String PBKDF2_SHA1 = "PBKDF2WithHmacSHA1"; // legacy only + private static final String AES = "AES"; + private static final String AES_CBC = "AES/CBC/PKCS5Padding"; + private static final String AES_ECB = "AES/ECB/PKCS5Padding"; // legacy migration only + + // DateTimeFormatter is immutable and thread-safe — no SimpleDateFormat. + private static final DateTimeFormatter TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + // Cache MnemonicCode: its constructor reads the BIP39 word list from disk. + private static final MnemonicCode MNEMONIC_CODE; + + static { + try { + MNEMONIC_CODE = new MnemonicCode(); + } catch (IOException e) { + throw new ExceptionInInitializerError( + "Failed to load BIP39 word list: " + e.getMessage()); + } + } + + // Characters accepted as "special" by calculatePasswordStrength. + private static final String SPECIAL_CHARS = "~!@#$%^&*()_-+=[]{|};:',.<>?/\\"; + + // ------------------------------------------------------------------------- + // Instance + // ------------------------------------------------------------------------- + + private final ECKey ecKey; + + /** + * Wrap an existing {@link ECKey}. + * + * @param key the key to handle + */ + public KeyHandler(ECKey key) { + this.ecKey = key; + } + + /** Return the underlying {@link ECKey}. */ + public ECKey getBaseECKey() { + return ecKey; + } + + /** Return a public-only view of the underlying key. */ + public ECKey getPublicKey() { + return ECKey.fromPublicOnly(ecKey.getPubKey()); + } + + // ========================================================================= + // Public API + // ========================================================================= + + /** + * Return {@code true} if a wallet file already exists on disk. + */ + public static boolean existsBaseECKeyFromLocal() { + return keyFile().exists(); + } + + /** + * Decrypt and return the mnemonic seed phrase. + * + *

If no wallet file exists, a new one is generated and persisted. + * Legacy wallets (V1 / AES-ECB) are migrated to V2 (AES-CBC) transparently. + * + * @param passphrase caller-owned char array; must be zeroed by the + * caller immediately after this method returns + * @return the mnemonic word list, or {@code null} if decryption fails + */ + public static List getBaseSeed(char[] passphrase) { + File file = keyFile(); + if (!file.exists()) { + return generateAndPersistNewSeed(passphrase, file); + } + try { + WalletData data = readWalletFile(file); + if (data.version == VERSION_1_SHA1) { + LOGGER.info("[security] Legacy V1 wallet detected — migrating to V2 (SHA-256/CBC)"); + char[] legacyPassphrase = null; + try { + legacyPassphrase = sha256ToChars(passphrase); + String seed = decryptSeedEcb(legacyPassphrase, data.encrypted, data.salt); + migrateToNewFormat(passphrase, seed, file); + return Arrays.asList(seed.split("\\s+")); + } finally { + if (legacyPassphrase != null) Arrays.fill(legacyPassphrase, '\0'); + } + } + String seed = decryptSeedCbc(passphrase, data.encrypted, data.salt, data.iv); + return Arrays.asList(seed.split("\\s+")); + } catch (BadPaddingException e) { + // Wrong password — expected failure, low log level. + LOGGER.finer("[security] Decryption failed — wrong passphrase or corrupted wallet"); + return null; + } catch (IOException e) { + LOGGER.warning("[security] Cannot read wallet file" + e.getMessage()); + return null; + } catch (Exception e) { + LOGGER.warning("[security] Unexpected error reading wallet" + e.getMessage()); + return null; + } + } + + /** + * Import a wallet from a BIP39 mnemonic word list. + * + * @param mnemonicList the BIP39 word list (12 / 15 / 18 / 21 / 24 words) + * @param passphrase caller-owned char array; must be zeroed by the + * caller immediately after this method returns + * @return {@code true} on success + * @throws IllegalArgumentException if {@code mnemonicList} is null or empty + */ + public static boolean importFromMnemonic(List mnemonicList, char[] passphrase) { + if (mnemonicList == null || mnemonicList.isEmpty()) { + throw new IllegalArgumentException("Mnemonic list cannot be null or empty"); + } + byte[] entropy = null; + try { + entropy = MNEMONIC_CODE.toEntropy(mnemonicList); + DeterministicSeed seed = new DeterministicSeed( + entropy, "", System.currentTimeMillis() / 1000); + List derived = Objects.requireNonNull(seed.getMnemonicCode()); + if (!derived.equals(mnemonicList)) { + return false; + } + String mnemonic = Joiner.on(" ").join(derived); + return writeInitialData(keyFile(), mnemonic, passphrase); + } catch (MnemonicException e) { + LOGGER.warning("Failed to convert mnemonic to entropy" + e.getMessage()); + return false; + } finally { + if (entropy != null) Arrays.fill(entropy, (byte) 0); + } + } + + /** + * Convert a BIP39 mnemonic word list to its raw entropy bytes. + * + * @param mnemonicList the mnemonic word list + * @return entropy bytes, or {@code null} on failure + */ + public static byte[] mnemonicToEntropy(List mnemonicList) { + try { + return MNEMONIC_CODE.toEntropy(mnemonicList); + } catch (Exception e) { + LOGGER.warning("Failed to convert mnemonic to entropy" + e.getMessage()); + return null; + } + } + + /** + * Parse a space-separated mnemonic string into a word list. + * Tolerates leading/trailing whitespace and runs of multiple spaces. + * + * @param mnemonic the raw mnemonic string + * @return list of mnemonic words + */ + public static List getMnemonicFromString(String mnemonic) { + return Arrays.asList(mnemonic.trim().split("\\s+")); + } + + /** + * Score password strength from 0 (too short) to 10 (all criteria met). + * + * + * + * + * + * + * + * + * + *
CriterionPoints
8–9 characters+1
10+ characters+2
Contains digit+2
Contains lowercase+2
Contains uppercase+2
Contains special char+2
+ * + * @param password the password to evaluate + * @return score in [0, 10] + */ + public static int calculatePasswordStrength(char[] password) { + if (password.length < 8) return 0; + + int score = password.length >= 10 ? 2 : 1; + boolean hasDigit = false, hasLower = false, hasUpper = false, hasSpecial = false; + + for (char c : password) { + if (Character.isDigit(c)) { + hasDigit = true; + } else if (Character.isLowerCase(c)) { + hasLower = true; + } else if (Character.isUpperCase(c)) { + hasUpper = true; + } else if (SPECIAL_CHARS.indexOf(c) >= 0) { + hasSpecial = true; + } + } + + if (hasDigit) score += 2; + if (hasLower) score += 2; + if (hasUpper) score += 2; + if (hasSpecial) score += 2; + return score; + } + + // ========================================================================= + // Private — key derivation and cipher + // ========================================================================= + + /** + * Hash a raw password with SHA-256 and return the hex digest as a char[]. + * Used only for decrypting legacy V1 wallets where the passphrase was + * pre-hashed via {@code LoginUtils.loginToEntropy()} before PBKDF2. + */ + private static char[] sha256ToChars(char[] input) { + byte[] hash = null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + hash = digest.digest(encodeUtf8Chars(input)); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) { + if ((0xff & b) < 0x10) + hex.append("0").append(Integer.toHexString(0xFF & b)); + else + hex.append(Integer.toHexString(0xFF & b)); + } + return hex.toString().toCharArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to compute SHA-256 for legacy migration" + e.getMessage()); + } finally { + if (hash != null) Arrays.fill(hash, (byte) 0); + } + } + + /** + * Encode a char[] as UTF-8 bytes without creating an intermediate String. + * Caller must zero the returned byte array after use. + */ + private static byte[] encodeUtf8Chars(char[] chars) { + ByteBuffer buf = StandardCharsets.UTF_8.encode( + CharBuffer.wrap(chars)); + byte[] bytes = new byte[buf.remaining()]; + buf.get(bytes); + return bytes; + } + + /** + * Derive a 256-bit AES key from a passphrase using PBKDF2. + * The intermediate raw key bytes are zeroed before returning. + */ + private static SecretKey deriveKey(char[] passphrase, byte[] salt, + String algorithm, int iterations) { + PBEKeySpec spec = new PBEKeySpec(passphrase, salt, iterations, KEY_LENGTH); + try { + byte[] raw = SecretKeyFactory.getInstance(algorithm) + .generateSecret(spec) + .getEncoded(); + try { + return new SecretKeySpec(raw, AES); + } finally { + Arrays.fill(raw, (byte) 0); + } + } catch (Exception e) { + throw new RuntimeException("Failed to derive encryption key" + e.getMessage()); + } finally { + spec.clearPassword(); + } + } + + /** + * Encrypt {@code seedBytes} with AES-CBC and a freshly generated random IV. + * + * @return {@code String[2]} — {@code [0]} = Base64 IV, {@code [1]} = Base64 ciphertext + */ + private static String[] encryptBaseSeedWithIv(char[] passphrase, + byte[] seedBytes, byte[] salt) { + SecretKey key = deriveKey(passphrase, salt, PBKDF2_SHA256, PBKDF2_ITERATIONS_SHA256); + try { + byte[] iv = new byte[IV_LENGTH]; + SecureRandom.getInstanceStrong().nextBytes(iv); + Cipher cipher = Cipher.getInstance(AES_CBC); + cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv)); + byte[] encrypted = cipher.doFinal(seedBytes); + return new String[]{ + new String(Base64.encode(iv), StandardCharsets.UTF_8), + new String(Base64.encode(encrypted), StandardCharsets.UTF_8) + }; + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt seed" + e.getMessage()); + } + } + + /** + * Decrypt with the current V2 scheme: AES-CBC, PBKDF2-SHA-256 @ 100 000 iterations. + */ + private static String decryptSeedCbc(char[] passphrase, byte[] encrypted, + byte[] salt, byte[] iv) throws Exception { + if (iv == null) throw new IllegalArgumentException("IV must not be null for CBC decryption"); + SecretKey key = deriveKey(passphrase, salt, PBKDF2_SHA256, PBKDF2_ITERATIONS_SHA256); + Cipher cipher = Cipher.getInstance(AES_CBC); + cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } + + /** + * Decrypt with the legacy V1 scheme: AES-ECB, PBKDF2-SHA-1 @ 16 384 iterations. + * Do not use for anything other than migrating legacy wallets. + */ + private static String decryptSeedEcb(char[] passphrase, byte[] encrypted, + byte[] salt) throws Exception { + SecretKey key = deriveKey(passphrase, salt, PBKDF2_SHA1, PBKDF2_ITERATIONS_SHA1); + Cipher cipher = Cipher.getInstance(AES_ECB); + cipher.init(Cipher.DECRYPT_MODE, key); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } + + // ========================================================================= + // Private — file I/O + // ========================================================================= + + /** Canonical location of the wallet file. Single source of truth. */ + private static File keyFile() { + return new File(ConfigHelper.getLocalDataDirectory(), "key.dat"); + } + + /** + * Read and parse a wallet file. + * + * @param file the wallet file to read + * @return parsed {@link WalletData} + * @throws IOException if the file is missing, incomplete, or corrupted + */ + private static WalletData readWalletFile(File file) throws IOException { + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String firstLine = reader.readLine(); + int version = detectWalletVersion(firstLine); + + String saltB64, ivB64 = null, encB64; + if (version == VERSION_2_SHA256) { + saltB64 = reader.readLine(); + ivB64 = reader.readLine(); + encB64 = reader.readLine(); + } else { + // V1: first line IS the salt; no IV. + saltB64 = firstLine; + encB64 = reader.readLine(); + } + + if (saltB64 == null || encB64 == null) { + throw new IOException("Wallet file is incomplete or corrupted"); + } + + return new WalletData( + version, + Base64.decode(saltB64), + ivB64 != null ? Base64.decode(ivB64) : null, + Base64.decode(encB64) + ); + } + } + + /** + * Write the versioned 4-line wallet format to {@code file}. + * Creates parent directories if they do not exist. + */ + private static void writeWalletFile(File file, byte[] salt, + String ivB64, String encryptedSeed) throws IOException { + file.getParentFile().mkdirs(); + try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { + writer.write(VERSION_HEADER + CURRENT_VERSION); + writer.newLine(); + writer.write(new String(Base64.encode(salt), StandardCharsets.UTF_8)); + writer.newLine(); + writer.write(ivB64); + writer.newLine(); + writer.write(encryptedSeed); + writer.newLine(); + } + } + + /** + * Move the current wallet file into the {@code backups/} subdirectory with + * a timestamp suffix. + * + * @param keyFile the wallet file to back up + * @return the backup {@link File}, or {@code null} if there was nothing to back up + */ + private static File backupKeyFile(File keyFile) { + if (!keyFile.exists()) return null; + + File backupsDir = new File(ConfigHelper.getLocalDataDirectory(), "backups"); + if (!backupsDir.exists() && !backupsDir.mkdirs()) { + LOGGER.warning("[security] Failed to create backups directory: " + backupsDir.getPath()); + } + + String timestamp = TIMESTAMP_FORMAT.format(LocalDateTime.now()); + File backup = new File(backupsDir, "key-" + timestamp + ".dat"); + if (!keyFile.renameTo(backup)) { + LOGGER.warning("[security] Failed to move wallet to backup location"); + return null; + } + LOGGER.info("[security] Wallet backup created: " + backup.getPath()); + return backup; + } + + /** Inspect the first line of a wallet file to determine its format version. */ + private static int detectWalletVersion(String firstLine) { + if (firstLine != null && firstLine.startsWith(VERSION_HEADER)) { + try { + return Integer.parseInt(firstLine.substring(VERSION_HEADER.length())); + } catch (NumberFormatException e) { + LOGGER.warning("[security] Unrecognised version header — treating wallet as legacy V1"); + } + } + return VERSION_1_SHA1; + } + + // ========================================================================= + // Private — wallet lifecycle + // ========================================================================= + + /** Generate a fresh BIP39 seed, persist it, and return the mnemonic word list. */ + private static List generateAndPersistNewSeed(char[] passphrase, File file) { + try { + DeterministicSeed seed = new DeterministicSeed( + SecureRandom.getInstanceStrong(), 128, ""); + String mnemonic = Joiner.on(" ").join( + Objects.requireNonNull(seed.getMnemonicCode())); + if (writeInitialData(file, mnemonic, passphrase)) { + return seed.getMnemonicCode(); + } + return null; + } catch (NoSuchAlgorithmException e) { + LOGGER.severe("Failed to obtain strong SecureRandom"); + return null; + } + } + + /** + * Back up any existing wallet, then encrypt and persist the mnemonic. + * + * @param keyFile destination wallet file + * @param mnemonic space-separated mnemonic string + * @param passphrase caller-owned; never copied to a String + * @return {@code true} on success + */ + private static boolean writeInitialData(File keyFile, String mnemonic, char[] passphrase) { + backupKeyFile(keyFile); + + byte[] salt = null; + byte[] mnemonicBytes = null; + try { + salt = new byte[SALT_LENGTH]; + SecureRandom.getInstanceStrong().nextBytes(salt); + + mnemonicBytes = mnemonic.getBytes(StandardCharsets.UTF_8); + String[] parts = encryptBaseSeedWithIv(passphrase, mnemonicBytes, salt); + writeWalletFile(keyFile, salt, parts[0], parts[1]); + + LOGGER.info("[security] Wallet created with AES-256-CBC / PBKDF2-SHA-256"); + return true; + } catch (Exception e) { + LOGGER.severe("[security] Failed to create wallet file"); + return false; + } finally { + if (salt != null) Arrays.fill(salt, (byte) 0); + if (mnemonicBytes != null) Arrays.fill(mnemonicBytes, (byte) 0); + } + } + + /** + * Re-encrypt a legacy V1 wallet in the V2 format. + * + *

Safety contract: + *

    + *
  1. The legacy file is renamed to a timestamped legacy-backup before any write. + *
  2. After writing, the new file is verified by attempting decryption. + *
  3. If verification fails, the new file is deleted and the legacy backup is + * restored. If restoration itself fails, a {@link RuntimeException} is thrown + * so the caller knows the wallet is in an inconsistent state. + *
+ */ + private static void migrateToNewFormat(char[] passphrase, String seed, File keyFile) { + File legacyBackup = null; + byte[] newSalt = null; + byte[] seedBytes = null; + + try { + validateMigrationReady(keyFile); + + // Create a distinctly named legacy backup (not in the backups/ subdir, so + // it is easy to find and recover manually). + String timestamp = TIMESTAMP_FORMAT.format(LocalDateTime.now()); + legacyBackup = new File(ConfigHelper.getLocalDataDirectory(), + "key-backup-legacy-" + timestamp + ".dat"); + if (!keyFile.renameTo(legacyBackup)) { + throw new RuntimeException("Cannot back up legacy wallet — migration aborted"); + } + + newSalt = new byte[SALT_LENGTH]; + seedBytes = seed.getBytes(StandardCharsets.UTF_8); + SecureRandom.getInstanceStrong().nextBytes(newSalt); + + String[] parts = encryptBaseSeedWithIv(passphrase, seedBytes, newSalt); + writeWalletFile(keyFile, newSalt, parts[0], parts[1]); + + if (!validateMigration(passphrase, keyFile)) { + throw new RuntimeException("Post-migration validation failed — new file is unreadable"); + } + + LOGGER.info("[security] Wallet successfully migrated to V2 (SHA-256/CBC)"); + + } catch (Exception e) { + LOGGER.severe("[security] Migration failed: " + e.getMessage()); + + // Rollback: remove the (potentially partial) new file, then restore the backup. + keyFile.delete(); + if (legacyBackup != null && legacyBackup.exists()) { + if (!legacyBackup.renameTo(keyFile)) { + throw new RuntimeException( + "[security] CRITICAL: migration failed AND backup restoration failed" + e.getMessage()); + } + LOGGER.info("[security] Legacy wallet restored from backup"); + } else { + throw new RuntimeException("Migration failed with no backup available" + e.getMessage()); + } + } finally { + if (newSalt != null) Arrays.fill(newSalt, (byte) 0); + if (seedBytes != null) Arrays.fill(seedBytes, (byte) 0); + } + } + + /** Verify that the directory is writable before attempting migration. */ + private static void validateMigrationReady(File keyFile) { + if (!keyFile.getParentFile().canWrite()) { + throw new RuntimeException( + "No write permission on wallet directory — migration aborted"); + } + File backupsDir = new File(ConfigHelper.getLocalDataDirectory(), "backups"); + if (!backupsDir.exists() && !backupsDir.mkdirs()) { + throw new RuntimeException("Cannot create backup directory — migration aborted"); + } + } + + /** + * Verify that the migrated wallet can be decrypted and yields a valid BIP39 + * word count (12, 15, 18, 21, or 24). + */ + private static boolean validateMigration(char[] passphrase, File keyFile) { + try { + WalletData data = readWalletFile(keyFile); + String decrypted = decryptSeedCbc(passphrase, data.encrypted, data.salt, data.iv); + int wordCount = decrypted.trim().split("\\s+").length; + boolean valid = wordCount == 12 || wordCount == 15 || wordCount == 18 + || wordCount == 21 || wordCount == 24; + if (!valid) { + LOGGER.warning("[security] Migration validation: unexpected word count " + wordCount); + } + return valid; + } catch (Exception e) { + LOGGER.warning("[security] Migration validation failed: " + e.getMessage()); + return false; + } + } + + // ========================================================================= + // WalletData — immutable value holder + // ========================================================================= + + private static final class WalletData { + final int version; + final byte[] salt; + final byte[] iv; // null for V1 (ECB) wallets + final byte[] encrypted; + + WalletData(int version, byte[] salt, byte[] iv, byte[] encrypted) { + this.version = version; + this.salt = salt; + this.iv = iv; + this.encrypted = encrypted; + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/ActiveCoinChangedEventListener.java b/src/main/java/io/xlite/daemon/app/net/ActiveCoinChangedEventListener.java new file mode 100644 index 0000000..f2dc18c --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/ActiveCoinChangedEventListener.java @@ -0,0 +1,7 @@ +package io.xlite.daemon.app.net; + +public interface ActiveCoinChangedEventListener { + + void onActiveCoinChanged(CoinInstance newActiveCoin); + +} diff --git a/src/main/java/io/xlite/daemon/app/net/CoinInstance.java b/src/main/java/io/xlite/daemon/app/net/CoinInstance.java new file mode 100644 index 0000000..c060b92 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/CoinInstance.java @@ -0,0 +1,1149 @@ +package io.xlite.daemon.app.net; + +import com.google.common.base.Joiner; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.subgraph.orchid.encoders.Hex; +//import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.Version; +import io.xlite.daemon.app.crypto.KeyHandler; +import io.xlite.daemon.app.net.api.JSONRPCController; +import io.xlite.daemon.app.net.api.JSONRPCServer; +import io.xlite.daemon.app.net.protocols.blocknet.*; +import io.xlite.daemon.app.net.xrouter.XRouterMessage; +import io.xlite.daemon.app.net.xrouter.XRouterPacketManager; +import io.xlite.daemon.app.util.AddressBalance; +import io.xlite.daemon.app.util.AddressDiscoveryService; +import io.xlite.daemon.app.util.CloudTransaction; +import io.xlite.daemon.app.util.ConfigHelper; +import io.xlite.daemon.app.util.Sats; +import io.xlite.daemon.app.util.UTXO; +import io.xlite.daemon.app.util.history.Transaction; +import io.xlite.daemon.app.wallet.WalletHelper; +import org.bitcoinj.core.*; +import org.bitcoinj.utils.BtcFormat; +import org.bitcoinj.utils.ListenerRegistration; +import org.bitcoinj.utils.MonetaryFormat; +import org.bitcoinj.utils.Threading; +import org.bitcoinj.wallet.DeterministicSeed; +import org.bitcoinj.wallet.Wallet; +import org.json.JSONArray; + +import java.math.BigDecimal; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class CoinInstance { + public static class CoinError { + public enum CoinErrorCode { + BADPASSWORD, UNSUPPORTEDCOIN, BADMNEMONIC, CHANGEPASSWORDFAILED + } + private final CoinErrorCode code; + private final String msg; + + public CoinError(String msg, CoinErrorCode code) { + this.msg = msg; + this.code = code; + } + + public CoinErrorCode getCode() { + return this.code; + } + + public String getMessage() { + return this.msg; + } + } + + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private static final int MINIMUM_UTXO_UPDATE_INTERVAL = 500; + + private static final int FORWARD_ADDRESS_COUNT = 0; + + private static final List coinInstances = new CopyOnWriteArrayList<>(); + private static volatile CoinInstance activeCurrency; + private static CoinTicker activeBlocknetNetwork = null; + private static CopyOnWriteArrayList> activeCoinChangedListeners = new CopyOnWriteArrayList<>(); + private static ConcurrentHashMap blockCounts = new ConcurrentHashMap<>(); + + private ConfigHelper configHelper; + private WalletHelper walletHelper = null; + private CoinTicker ticker; + private ConcurrentHashMap transactionList = new ConcurrentHashMap<>(); + private final CopyOnWriteArrayList addressKeyPairs = new CopyOnWriteArrayList<>(); + private final CopyOnWriteArrayList transactionObservableList = new CopyOnWriteArrayList<>(); + private BlocknetPeerGroup blocknetPeerGroup; + private BlocknetParameters blocknetNetworkParameters; + private NetworkParameters networkParameters; + private Wallet wallet; + private BlockChain chain; + private boolean hasXRouter = false; + private KeyHandler keyHandler; + private XRouterPacketManager xRouterPacketManager = null; + private int rpcPort = -1; + private boolean testnet = false; + // Volatile: written by reloadconfig on a Netty event-loop thread and + // read by lifecycle code (deinit) on other threads. + private volatile JSONRPCServer coinRPCServer = null; + private volatile long lastUtxoUpdate = 0; + private final AtomicInteger updateFailures = new AtomicInteger(0); + // Last wall-clock time a fresh height was accepted from the backend. + // A stuck clock here with a rising failure streak means the served + // height is stale — both are now logged, never silent. + private volatile long lastBlockCountMillis = 0; + private int generatedAddressCount; + private AddressDiscoveryService discoveryService = null; + private static volatile boolean addressDiscoveryEnabled = true; + + private CoinInstance(CoinTicker ticker, ConfigHelper configHelper) { + this.ticker = ticker; + this.configHelper = configHelper; + + addBlockCount(ticker, 0); + + if (isBlocknetNetwork()) + setActiveCurrency(this); + } + + /** + * Return mnemonic seed from wallet stored on disk. Correct passphrase required. + * Returns empty string on error or failure to retrieve mnemonic (or if mnemonic + * doesn't exist). + * @param pw caller-owned char array; must be zeroed by the caller after use + * @return String + */ + public static String getMnemonicForPw(char[] pw) { + if (!KeyHandler.existsBaseECKeyFromLocal()) + return ""; + + List seed = KeyHandler.getBaseSeed(pw); + if (seed == null) + return ""; + return Joiner.on(" ").join(seed); + } + + public static int getBlockCountByTicker(CoinTicker ticker) { + if (!blockCounts.containsKey(ticker)) { + return -1; + } + + return blockCounts.get(ticker).get(); + } + + public static List getCoinInstances() { + return coinInstances; + } + + public boolean isBlocknetNetwork() { + return getTicker() == CoinTicker.BLOCKNET; + } + + public static void setActiveCurrency(CoinInstance newActiveCurrency) { + activeCurrency = newActiveCurrency; + + for (ListenerRegistration registration : activeCoinChangedListeners) { + if (registration.executor == Threading.SAME_THREAD) { + registration.listener.onActiveCoinChanged(activeCurrency); + } + } + } + + public XRouterPacketManager getXRouterPacketManager() { + return xRouterPacketManager; + } + + public static void addActiveCoinChangedListener(ActiveCoinChangedEventListener listener) { + activeCoinChangedListeners.add(new ListenerRegistration<>(listener, Threading.SAME_THREAD)); + } + + public static CoinInstance getActiveCurrency() { + return activeCurrency; + } + + public AddressBalance getAddress(String addressB58) { + for (AddressBalance address : addressKeyPairs) { + if (address.getAddress().toBase58().equals(addressB58)) + return address; + } + + return null; + } + + public AddressBalance generateAddress(boolean updateConfig) { + AddressBalance addressKeyPair = getWalletHelper().generateAddress(); + LegacyAddress address = (LegacyAddress) addressKeyPair.getAddress(); + DumpedPrivateKey privateKey = addressKeyPair.getPrivateKey(); + addressKeyPairs.add(addressKeyPair); + LOGGER.finer("[wallet] Generated new address, have " + addressKeyPairs.size() + ": " + address.toBase58()); + + if (updateConfig) { + configHelper.setAddressCount(configHelper.getAddressCount() + 1); + configHelper.writeConfig(); + generatedAddressCount = configHelper.getAddressCount(); + } + + return addressKeyPair; + } + + public void importPrivateKey(String privKey) { + AddressBalance addressKeyPair = getWalletHelper().generateFromPrivateKey(privKey); + AddressBalance addrExists = addressKeyPairs.stream() + .filter(e -> e.getAddress().equals(addressKeyPair.getAddress())).findAny().orElse(null); + + if (addrExists == null) + addressKeyPairs.add(addressKeyPair); + } + + public CoinTicker getTicker() { + return ticker; + } + + private static CoinInstance getInstanceByTicker(CoinTicker ticker) { + for (CoinInstance instance : coinInstances) { + if (instance.getTicker() == ticker) { + return instance; + } + } + + return null; + } + + public static CoinTicker getActiveBlocknetNetwork() { + return activeBlocknetNetwork; + } + + public static CoinInstance getInstance(CoinTicker ticker) { + CoinInstance existing = getInstanceByTicker(ticker); + if (existing != null) { + return existing; + } + + ConfigHelper cfg = null; + if (ticker != CoinTicker.BLOCKNET) { + cfg = new ConfigHelper(CoinTickerUtils.tickerToString(ticker)); + if (!cfg.isRpcEnabled()) { + return null; + } + } + + synchronized (CoinInstance.class) { + CoinInstance instance = getInstanceByTicker(ticker); + + if (instance == null) { + if (cfg == null) { + cfg = new ConfigHelper(CoinTickerUtils.tickerToString(ticker)); + } + instance = new CoinInstance(ticker, cfg); + if (ticker == CoinTicker.BLOCKNET) + coinInstances.add(0, instance); + else + coinInstances.add(instance); + } + + if (ticker == CoinTicker.BLOCKNET) { + activeBlocknetNetwork = ticker; + } + + if (getActiveBlocknetNetwork() != null && ticker == CoinTicker.BLOCKNET) { + return getInstanceByTicker(activeBlocknetNetwork); + } + + return instance; + } + } + + /** + * Change the password. Recreates the wallet file and encrypts with new password. + * @param oldPassword caller-owned char array; must be zeroed by the caller after use + * @param newPassword caller-owned char array; must be zeroed by the caller after use + * @return Error or null + */ + public static CoinError changePassword(char[] oldPassword, char[] newPassword) { + if (!KeyHandler.existsBaseECKeyFromLocal()) { + LOGGER.warning("[wallet] Unable to change the password: Wallet not found on disk"); + return new CoinError("Unable to change the password: Wallet not found on disk", + CoinError.CoinErrorCode.CHANGEPASSWORDFAILED); + } + + List baseSeed = KeyHandler.getBaseSeed(oldPassword); + if (baseSeed == null) { + LOGGER.warning("[wallet] Unable to change the password: Incorrect password"); + return new CoinError("Unable to change the password: Incorrect password", + CoinError.CoinErrorCode.CHANGEPASSWORDFAILED); + } + + DeterministicSeed seed = new DeterministicSeed(baseSeed, null, "", System.currentTimeMillis() / 1000); + List mnemonic = seed.getMnemonicCode(); + + if (!KeyHandler.importFromMnemonic(mnemonic, newPassword)) { + LOGGER.warning("[wallet] Unable to change the password: Failed to create new wallet file"); + return new CoinError("Unable to change the password: Failed to create new wallet file", + CoinError.CoinErrorCode.CHANGEPASSWORDFAILED); + } + + return null; + } + + public NetworkParameters getNetworkParameters() { + return networkParameters; + } + + private BlocknetParameters getBlocknetNetworkParameters() { + return blocknetNetworkParameters; + } + + public boolean hasXRouter() { + return hasXRouter; + } + + public void deinit() { + if (getTicker() == getActiveBlocknetNetwork() && blocknetPeerGroup != null) { + blocknetPeerGroup.stop(); + } + + if (coinRPCServer != null) { + boolean interruptedDuringJoin = false; + try { + coinRPCServer.deinit(); + // Bounded: an untimeouted join here would stall the JVM + // shutdown hook indefinitely on a wedged server thread. + coinRPCServer.join(10_000); + } catch (InterruptedException e) { + // Preserve the flag and say so — a silent swallow here would + // hide shutdown-latency facts even when the server dies. + Thread.currentThread().interrupt(); + interruptedDuringJoin = true; + LOGGER.warning("[coin] deinit join interrupted for " + + CoinTickerUtils.tickerToString(ticker)); + } catch (Exception e) { + LOGGER.warning("[coin] Error deinitializing RPC server for " + CoinTickerUtils.tickerToString(ticker) + e.getMessage()); + } + if (coinRPCServer.isAlive()) + LOGGER.warning("[coin] RPC server thread for " + + CoinTickerUtils.tickerToString(ticker) + + " still alive after deinit" + + (interruptedDuringJoin ? " (join was interrupted)" : "")); + } + } + + public CoinError init(char[] pw, String userMnemonic, boolean isMnemonic) { + return init(pw, userMnemonic, isMnemonic, false); + } + + public CoinError init(char[] pw, String userMnemonic, boolean isMnemonic, boolean xliteRPC) { + switch (ticker) { + case BLOCKNET: { + LOGGER.fine("[coin] Initializing for Blocknet main network."); + blocknetNetworkParameters = new BlocknetNetworkParameters(); + networkParameters = blocknetNetworkParameters; + hasXRouter = true; + rpcPort = 41419; + break; + } + case BITCOIN: { + LOGGER.fine("[coin] Initializing for Bitcoin main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 8332; + break; + } + // case BITCOIN_CASH: { + // LOGGER.fine("[coin] Initializing for BitcoinCash main network."); + // networkParameters = new io.xlite.daemon.app.net.protocols.bitcoincash.BitcoinCashNetworkParametersLegacy(); + // rpcPort = 48332; + // break; + // } + case LITECOIN: { + LOGGER.fine("[coin] Initializing for Litecoin main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 9332; + break; + } + case DASHCOIN: { + LOGGER.fine("[coin] Initializing for Dashcoin main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 9998; + break; + } + case DIGIBYTE: { + LOGGER.fine("[coin] Initializing for Digibyte main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 14022; + break; + } + case DOGECOIN: { + LOGGER.fine("[coin] Initializing for Dogecoin main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 22555; + break; + } + case SYSCOIN: { + LOGGER.fine("[coin] Initializing for Syscoin main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 8370; + break; + } + case PIVX: { + LOGGER.fine("[coin] Initializing for Pivx main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 9951; + break; + } + case UNOBTANIUM: { + LOGGER.fine("[coin] Initializing for Unobtanium main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 65111; + break; + } + case PKOIN: { + LOGGER.fine("[coin] Initializing for Pocketcoin main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 37071; + break; + } + case RAVENCOIN: { + LOGGER.fine("[coin] Initializing for Ravencoin main network."); + CoinError err = loadMigratedParams(); + if (err != null) return err; + rpcPort = 8766; + break; + } + default: { + LOGGER.fine("[coin] ERROR: Invalid/unsupported network: " + ticker.toString()); + return new CoinError("Unsupported coin", CoinError.CoinErrorCode.UNSUPPORTEDCOIN); + } + } + + configHelper.writeConfig(); + + if (xliteRPC) { + rpcPort = rpcPort + 1; + + if (!configHelper.setRpcPort(rpcPort)) { + LOGGER.warning("[coin] Failed to allocate RPC port, skipping RPC config"); + } + configHelper.writeConfig(); + } + + Context.propagate(new Context(networkParameters)); + + List baseSeed; + boolean existsOnDisk = false; + + if (isMnemonic) { + baseSeed = splitMnemonicChars(pw); + } else { + if (KeyHandler.existsBaseECKeyFromLocal()) { + existsOnDisk = true; + if (userMnemonic != null) { + LOGGER.warning("[wallet] Wallet already exists on disk, ignoring provided mnemonic"); + } + } else if (userMnemonic != null) { + if (!KeyHandler.importFromMnemonic(Arrays.asList(userMnemonic.split(" ")), pw)) { + LOGGER.warning("[wallet] Unable to create wallet from mnemonic"); + return new CoinError("Unable to create wallet from mnemonic", CoinError.CoinErrorCode.BADMNEMONIC); + } + } + + baseSeed = KeyHandler.getBaseSeed(pw); + } + + if (baseSeed == null) { + LOGGER.warning("[wallet] Possible Bad password: Unable to import or create base seed!"); + return new CoinError("Bad password", CoinError.CoinErrorCode.BADPASSWORD); + } + + // In-memory wallet only + DeterministicSeed seed = new DeterministicSeed(baseSeed, null, "", System.currentTimeMillis() / 1000); + wallet = Wallet.fromSeed(networkParameters, seed); + if (isBlocknetNetwork()) { + String mnemonic = getMnemonic(); + // LOGGER.fine("[wallet] Mnemonic = " + mnemonic); + } + + // RUN ADDRESS DISCOVERY ONLY DURING WALLET INITIALIZATION + // This ensures discovery runs once at wallet startup in ANY case + if (addressDiscoveryEnabled) { + LOGGER.fine("[coinAddressDiscoveryService created] Running address discovery"); + runAddressDiscovery(); + } else { + LOGGER.fine("[coin] Address discovery disabled"); + } + + // Make sure wallet addresses are available + generateForwardAddresses(true); + + if (configHelper.getRpcPort() == -1000) { + if (!configHelper.setRpcPort(rpcPort)) { + LOGGER.warning("[coin] Failed to allocate RPC port, RPC server will not start"); + } + configHelper.writeConfig(); + } else { + rpcPort = configHelper.getRpcPort(); + } + + if (configHelper.isRpcEnabled() && configHelper.validAuth() && rpcPort != -1) { + coinRPCServer = JSONRPCController.getRPCServer(this); + // Readiness is announced by JSONRPCServer itself AFTER a successful + // bind ("[rpc] RPC server listening for …") — do not log a + // success-shaped line before the socket exists. + LOGGER.finer("[rpc] Requesting start of JSON-RPC server for coin " + CoinTickerUtils.tickerToString(getTicker()) + " on port " + getRPCPort()); + + if (coinRPCServer.isAlive()) + coinRPCServer.deinit(); + + coinRPCServer.start(); + } + + // Blocknet Network / XRouter not used (Dec 10) +// if (isBlocknetNetwork() && hasXRouter()) { +// XRouterMessageSerializer xRouterMessageSerializer = (getBlocknetNetworkParameters()).getXRouterMessageSerializer(false); +// xRouterPacketManager = new XRouterPacketManager(xRouterMessageSerializer, blocknetNetworkParameters); +// LOGGER.fine("[coin] This network is a Blocknet network and supports XRouter. Our packet version is " + Integer.toString(XRouterPacketManager.getXRouterPacketVersion(), 16)); +// } else { +// LOGGER.fine("[coin] WARNING: This network (" + CoinTickerUtils.tickerToString(getTicker()) + ") does not support XRouter."); +// } +// +// if (isBlocknetNetwork()) { +// String userHome = ConfigHelper.getLocalDataDirectory(); +// Preconditions.checkNotNull(userHome); +// +// File spvDat = new File(userHome,"spv-" + CoinTickerUtils.tickerToString(getTicker()) + ".dat"); +// try { +// chain = new BlockChain(networkParameters, getWallet(), new SPVBlockStore(networkParameters, spvDat)); +// } catch (BlockStoreException e) { +// try { +// chain = new BlockChain(networkParameters, getWallet(), new SPVBlockStore(networkParameters, spvDat)); +// } catch (BlockStoreException ex) { +// LOGGER.warning("Error while initializing blockchain object!"); +// ex.printStackTrace(); +// return false; +// } +// } +// +// LOGGER.info("[coin] Connecting to the (" + getTicker().toString() + ") network."); +// +// if (getAddressKeyPairs().size() == 0) { +// LOGGER.fine("[peer] Have no addresses. Generating forward addresses."); +// +// generateForwardAddresses(true); +// } +// +// AddressBalance blockProofAddress = getAddressKeyPairs().get(0); +// +// keyHandler = new KeyHandler(blockProofAddress.getPrivateKey().getKey()); +// +// blocknetPeerGroup = new BlocknetPeerGroup(this, (BlocknetNetworkParameters) blocknetNetworkParameters, chain); +// connectToBlocknetNetwork(); +// } + + return null; + } + + private CoinError loadMigratedParams() { + try { + networkParameters = io.xlite.daemon.app.coinconfig.ConfigurableNetworkParameters + .from(io.xlite.daemon.app.coinconfig.CoinConfigRegistry + .get(CoinTickerUtils.tickerToString(ticker))); + return null; + } catch (IllegalStateException | IllegalArgumentException e) { + LOGGER.warning("[coin] [" + ticker + "] missing/invalid config: " + e.getMessage()); + return new CoinError("Unsupported coin", CoinError.CoinErrorCode.UNSUPPORTEDCOIN); + } + } + + private void generateForwardAddresses(boolean fromStartup) { + int configAddressCount = configHelper.getAddressCount(); + boolean updateConfig = false; + if (configAddressCount < FORWARD_ADDRESS_COUNT) { // minimum starting addresses + configAddressCount = FORWARD_ADDRESS_COUNT; + updateConfig = true; + } + + LOGGER.fine("[wallet] Generating " + configAddressCount + " forward addresses for network " + getTicker().toString() + "."); + + // Ensure that internal HD wallet pointer matches the count we're expecting. + // Required because wallet doesn't remember last HD wallet address prior to + // reboot. + if (fromStartup) { + for (int i = 0; i < generatedAddressCount; i++) { + getWalletHelper().generateAddress(); + } + } + for (int i = generatedAddressCount; i < configAddressCount; i++) { + generateAddress(false); + } + generatedAddressCount = configAddressCount; + + if (updateConfig) { + configHelper.setAddressCount(configAddressCount); + configHelper.writeConfig(); + } + } + + private void connectToBlocknetNetwork() { + try { + blocknetPeerGroup.start(); + } catch (Exception e) { + LOGGER.warning("[coin] Error initializing blocking client for " + CoinTickerUtils.tickerToString(ticker) + e.getMessage()); + return; + } + + LOGGER.fine("[coin] This network is connecting/connected."); + } + + public Wallet getWallet() { + return wallet; + } + + public String getMnemonic() { + return Joiner.on(" ").join(Objects.requireNonNull(getWallet().getKeyChainSeed().getMnemonicCode())); + } + + public double getAllBalances() { + double balance = 0; + + for (AddressBalance inst : getAddressKeyPairs()) { + balance += inst.getBalanceProp(); + } + + return balance; + } + + public String getAllBalancesFormatted() { + BtcFormat f = BtcFormat.getInstance(BtcFormat.COIN_SCALE); + return f.format(Coin.valueOf(Sats.fromWholeCoins(getAllBalances()))); + } + + public void sendXrGetTransaction(BlocknetPeer blocknetPeer, String txid) { + String currentCurrency = CoinTickerUtils.tickerToString(getTicker()); + + HashMap body = new HashMap<>(); + body.put("currency", currentCurrency); + body.put("txid", txid); + + getInstance(activeBlocknetNetwork).sendXrMessage(blocknetPeer, "xrGetTransaction", body); + } + + public void sendXrGetBlockCount(BlocknetPeer blocknetPeer) { + String currentCurrency = CoinTickerUtils.tickerToString(getTicker()); + + HashMap body = new HashMap<>(); + body.put("currency", currentCurrency); + + getInstance(activeBlocknetNetwork).sendXrMessage(blocknetPeer, "xrGetBlockCount", body); + } + + public void sendXrGetUtxos(BlocknetPeer blocknetPeer) { + if (System.currentTimeMillis() - lastUtxoUpdate < MINIMUM_UTXO_UPDATE_INTERVAL) { + LOGGER.fine("[coin] Aborting UTXO checking as the list was updated less than 1 second ago."); + return; + } + + String currentCurrency = CoinTickerUtils.tickerToString(getTicker()); + + HashMap body = new HashMap<>(); + body.put("currency", currentCurrency); + body.put("command", "xrmgetutxos"); + body.put("params", getInstance(CoinTickerUtils.stringToTicker(currentCurrency)).getUTXOParams()); + + getInstance(activeBlocknetNetwork).sendXrMessage(blocknetPeer, "xrService", body); + } + + public String sendXrMessage(BlocknetPeer blocknetPeer, String command, HashMap params) { + return sendXrMessage(blocknetPeer, UUID.randomUUID().toString(), command, params); + } + + public String sendXrMessage(BlocknetPeer blocknetPeer, String uuid, String command, HashMap params) { + XRouterMessage message = null; + + if (blocknetPeer == null || !blocknetPeer.getHaveConfig().get()) { + LOGGER.fine("[sendXrMessage] Config not received yet"); + return null; + } + + switch (command) { + case "xrGetBlockCount": { + String currency = (String) params.get("currency"); + + message = getXRouterPacketManager().getXrGetBlockCount( + blocknetPeer, + uuid, + currency, + keyHandler.getBaseECKey(), + keyHandler.getPublicKey()); + break; + } + case "xrService": { + String xrCustomCmd = (String) params.get("command"); + + ArrayList paramsList = (ArrayList) params.get("params"); + + message = getXRouterPacketManager().getXrService( + blocknetPeer, + uuid, + xrCustomCmd, + paramsList, + keyHandler.getBaseECKey(), + keyHandler.getPublicKey()); + break; + } + case "xrSendTransaction": { + String feePayment = (String) params.get("feetx"); + String transaction = (String) params.get("transaction"); + String currency = (String) params.get("currency"); + + message = getXRouterPacketManager().getXrSendTransaction( + blocknetPeer, + uuid, + feePayment, + currency, + transaction, + keyHandler.getBaseECKey(), + keyHandler.getPublicKey()); + break; + } + case "xrGetBlockHash": { + String feePayment = (String) params.get("feetx"); + String blockIndex = (String) params.get("blockIndex"); + String currency = (String) params.get("currency"); + + message = getXRouterPacketManager().getXrGetBlockHash( + blocknetPeer, + uuid, + feePayment, + currency, + blockIndex, + keyHandler.getBaseECKey(), + keyHandler.getPublicKey()); + break; + } + case "xrGetBlock": { + String feePayment = (String) params.get("feetx"); + String blockHash = (String) params.get("blockHash"); + String currency = (String) params.get("currency"); + + message = getXRouterPacketManager().getXrGetBlock( + blocknetPeer, + uuid, + feePayment, + currency, + blockHash, + keyHandler.getBaseECKey(), + keyHandler.getPublicKey()); + break; + } + case "xrGetTransaction": { + String txid = (String) params.get("txid"); + String currency = (String) params.get("currency"); + + message = getXRouterPacketManager().getXrGetTransaction( + blocknetPeer, + uuid, + currency, + txid, + keyHandler.getBaseECKey(), + keyHandler.getPublicKey()); + break; + } + case "xrGetConfig": { + message = getXRouterPacketManager().getXrGetConfig( + blocknetPeer, + uuid, + "self", + keyHandler.getBaseECKey(), + keyHandler.getPublicKey()); + break; + } + default: { + LOGGER.fine("[coin] ERROR: Unknown XRouter Message! Command: " + command); + uuid = null; + break; + } + } + + if (message != null) + blocknetPeerGroup.sendMessage(blocknetPeer, message); + + return uuid; + } + + public JsonArray getAllUTXOS() { + MonetaryFormat PLAIN_FORMAT = MonetaryFormat.BTC.minDecimals(8).repeatOptionalDecimals(1, 0).noCode(); + + JsonArray unspentTxsJSON = new JsonArray(); + for (AddressBalance addressBalance : getAddressKeyPairs()) { + for (UTXO utxo : addressBalance.getUtxos()) { + if (utxo.isSpent()) + continue; + + org.bitcoinj.core.UTXO bUtxo; + try { + bUtxo = utxo.createUTXO(); + } catch (Exception e) { + // One malformed/bech32 address must not kill the whole + // listunspent response — skip the row, keep the rest. + LOGGER.warning("[coin] Skipping unparseable UTXO for " + CoinTickerUtils.tickerToString(getTicker()) + + " (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")"); + continue; + } + + JsonObject utxoJSON = new JsonObject(); + utxoJSON.addProperty("txid", bUtxo.getHash().toString()); + utxoJSON.addProperty("vout", bUtxo.getIndex()); + utxoJSON.addProperty("address", bUtxo.getAddress()); + + Monetary monetary = new Monetary() { + @Override + public int smallestUnitExponent() { + return 8; + } + + @Override + public long getValue() { + return utxo.getValue(); + } + + @Override + public int signum() { + if (this.getValue() == 0) + return 0; + return this.getValue() < 0 ? -1 : 1; + } + }; + + BigDecimal amountDecimal = new BigDecimal(PLAIN_FORMAT.format(monetary).toString()); + + utxoJSON.addProperty("amount", amountDecimal); + utxoJSON.addProperty("scriptPubKey", new String(Hex.encode(bUtxo.getScript().getProgram()))); + utxoJSON.addProperty("spendable", true); + + int totalBlocks = CoinInstance.getBlockCountByTicker(getTicker()); + int confirmations = (totalBlocks - bUtxo.getHeight()) + 1; + if (bUtxo.getHeight() == 0) + confirmations = 0; + // Pre-first-poll the height cache holds a negative sentinel + // (-1); clamp so no caller ever sees negative confirmations. + confirmations = Math.max(0, confirmations); + + utxoJSON.addProperty("confirmations", confirmations); + + unspentTxsJSON.add(utxoJSON); + } + } + + return unspentTxsJSON; + } + + public JsonArray getAllTransactions() { + JsonArray transactionsJSON = new JsonArray(); + for (Transaction tx : transactionList.values()) { + JsonObject txJSON = new JsonObject(); + txJSON.addProperty("category", tx.getCategory()); + txJSON.addProperty("txid", tx.getTxid()); + txJSON.addProperty("blockhash", tx.getBlockhash()); + txJSON.addProperty("vout", tx.getVout()); + txJSON.addProperty("address", tx.getAddress()); + + txJSON.addProperty("amount", tx.getValue()); + txJSON.addProperty("fee", tx.getFee()); + txJSON.addProperty("trusted", false); + txJSON.addProperty("blocktime", tx.getBlocktime()); + txJSON.addProperty("time", tx.getBlocktime()); + + txJSON.addProperty("confirmations", tx.getConfirmations()); + + transactionsJSON.add(txJSON); + } + + + return transactionsJSON; + } + + public ArrayList getUTXOParams() { + ArrayList params = new ArrayList<>(); + params.add(CoinTickerUtils.tickerToString(getTicker())); + + JSONArray utxoAddresses = new JSONArray(); + for (AddressBalance addressBalance : getAddressKeyPairs()) { + utxoAddresses.put(addressBalance.getAddress().toBase58()); + } + + params.add(utxoAddresses.toString()); + + return params; + } + + public AddressBalance getAddressBalance(String address) { + for (AddressBalance addressBalance : getAddressKeyPairs()) { + if (addressBalance.getAddress().toBase58().equals(address)) { + return addressBalance; + } + } + + return null; + } + + public ConfigHelper getConfigHelper() { + return this.configHelper; + } + + public WalletHelper getWalletHelper() { + if (this.walletHelper == null) + this.walletHelper = new WalletHelper(this); + + return this.walletHelper; + } + + public void addBlockCount(CoinTicker ticker, Integer blockCount) { + // Plain set: the cache must track the true tip. The old Math.max + // ratchet pinned a stale-high height forever (e.g. after network + // switch or a rollback), and pre-first-poll zeros poisoned + // confirmation math. + blockCounts.computeIfAbsent(ticker, k -> new AtomicInteger(0)) + .set(blockCount); + // The constructor seeds 0: that initializes the map but is not a + // backend height — only a real (positive) tip starts the freshness + // clock, so "no height ever received" stays reachable and truthful. + if (blockCount != null && blockCount > 0) { + lastBlockCountMillis = System.currentTimeMillis(); + } + } + + public void addCloudTransaction(CloudTransaction cloudTransaction) { + synchronized (transactionObservableList) { + if (transactionObservableList.isEmpty()) { + transactionObservableList.add(cloudTransaction); + return; + } + + CloudTransaction tx = transactionObservableList.stream() + .filter(e -> e.getTxHash().equals(cloudTransaction.getTxHash())).findAny().orElse(null); + + if (tx == null) { + transactionObservableList.add(cloudTransaction); + } + } + } + + public void processUtxos(List utxoList) { + if (utxoList == null) { + LOGGER.warning("[coin-" + CoinTickerUtils.tickerToString(getTicker()) + "] processUtxos: null UTXO list received"); + return; + } + LOGGER.fine("[coin-" + CoinTickerUtils.tickerToString(getTicker()) + "] processUtxos: remote returned " + utxoList.size() + " UTXOs, tracking " + addressKeyPairs.size() + " addresses locally"); + int added = 0, skipped = 0; + + Set clearedAddresses = new HashSet<>(); + for (UTXO utxo : utxoList) { + String addr = utxo.getAddress(); + if (clearedAddresses.add(addr)) { + AddressBalance addressBalance = getAddress(addr); + if (addressBalance != null) { + addressBalance.clearUtxos(); + } + } + } + + for (UTXO utxo : utxoList) { + AddressBalance addressBalance = getAddress(utxo.getAddress()); + if (addressBalance == null) { + LOGGER.warning("[utxo-parser] Warning: Encountered non-tracked address in reply: " + utxo.getAddress()); + skipped++; + continue; + } + boolean isNewUtxo = addressBalance.addUtxo(utxo); + if (isNewUtxo) { + added++; + addCloudTransaction(new CloudTransaction(utxo)); + LOGGER.finer("[utxo-parser] Added new UTXO, address: " + utxo.getAddress() + " value: " + utxo.getAmount()); + } + } + LOGGER.fine("[coin-" + CoinTickerUtils.tickerToString(getTicker()) + "] processUtxos: added=" + added + ", skipped=" + skipped); + setLastUtxoUpdate(System.currentTimeMillis()); + } + + public void processHistoryTxs(List transactions) { + for (Transaction tx : transactions) + transactionList.put(tx.uid(), tx); + } + + private void setLastUtxoUpdate(long newUtxoTime) { + lastUtxoUpdate = newUtxoTime; + } + + public void reloadConfig() { + this.configHelper.loadConfig(); + rpcPort = configHelper.getRpcPort(); + if (configHelper.getAddressCount() != generatedAddressCount) + generateForwardAddresses(false); + + if (coinRPCServer == null) + return; // no rpc available, skip + + // Atomic retire+create: a concurrent reloadconfig must never + // receive the retiring server instance. + coinRPCServer = JSONRPCController.rebindRPCServer(this); + + LOGGER.finer("[rpc] Requesting start of JSON-RPC server for coin " + CoinTickerUtils.tickerToString(getTicker()) + " on port " + getRPCPort()); + coinRPCServer.start(); + + // Verify the rebind actually took — a silent bind failure used to + // leave the coin RPC dead while callers already got success. Budget + // exceeds the server's own 20x250ms retry window so a slow but + // successful late rebind cannot trip this alarm. + if (!coinRPCServer.awaitBound(10000)) { + LOGGER.severe("[rpc] Failed to rebind JSON-RPC server for coin " + + CoinTickerUtils.tickerToString(getTicker()) + + " on port " + getRPCPort() + " after reloadconfig" + + "; new-server state: " + + coinRPCServer.lifecycleState()); + } + } + + public KeyHandler getKeyHandler() { + return keyHandler; + } + + public BlocknetPeerGroup getBlocknetPeerGroup() { + return blocknetPeerGroup; + } + + public BlocknetPeer getBestBlocknetPeer(String currency) { + if (blocknetPeerGroup == null) + return null; + + return blocknetPeerGroup.getBestBlocknetPeer(currency); + } + + public List getAddressKeyPairs() { + return Collections.unmodifiableList(addressKeyPairs); + } + + public List getTransactionList() { + return Collections.unmodifiableList(transactionObservableList); + } + + public static AtomicInteger getBlockCount(CoinTicker ticker) { + return blockCounts.putIfAbsent(ticker, new AtomicInteger(0)); + } + + public static ConcurrentHashMap getBlockCounts() { + return blockCounts; + } + + public int getRPCPort() { + return rpcPort; + } + + public boolean isTestnet() { + return testnet; + } + + public static String getVersionString() { + return Version.SUBVERSION; + } + + /** + * Records one failed backend height update. + * @return the new consecutive-failure streak (drives staleness logging + * and {@link #isInstanceRunning}) + */ + public int incrementUpdateFailures() { + return updateFailures.incrementAndGet(); + } + + /** + * Records a successful backend height update. + * @return the streak just ended (0 when nothing had failed) + */ + public int resetUpdateFailures() { + return updateFailures.getAndSet(0); + } + + public int getUpdateFailures() { + return updateFailures.get(); + } + + /** Wall-clock millis of the last accepted backend height (0 = never). */ + public long getLastBlockCountMillis() { + return lastBlockCountMillis; + } + + public void runAddressDiscovery() { + String currency = CoinTickerUtils.tickerToString(this.getTicker()); + + if (!configHelper.isRpcEnabled()) { + LOGGER.fine("[coin-" + currency + "] RPC disabled, skipping address discovery"); + return; + } + + if (discoveryService == null) { + discoveryService = new AddressDiscoveryService(this); + LOGGER.fine("[coin-" + currency + "] AddressDiscoveryService created"); + } + + int discoveredCount = discoveryService.discoverAddressCount(); + discoveryService.clearExternalChainKey(); + int currentCount = configHelper.getAddressCount(); + + if (discoveredCount > currentCount) { + LOGGER.info("[coin-" + currency + "] Address discovery found " + + discoveredCount + " addresses (was " + currentCount + ")"); + + // Update config and generate missing addresses + configHelper.setAddressCount(discoveredCount); + configHelper.writeConfig(); + + LOGGER.info("[coin-" + currency + "] Updated address count to " + + discoveredCount); + } else { + LOGGER.fine("[coin-" + currency + "] No new addresses discovered, " + + "keeping current count: " + currentCount); + } + } + + public boolean isInstanceRunning() { + return updateFailures.get() < 5; + } + + public static void setAddressDiscoveryEnabled(boolean enabled) { + addressDiscoveryEnabled = enabled; + } + + public static boolean isAddressDiscoveryEnabled() { + return addressDiscoveryEnabled; + } + + /** + * Split a mnemonic char[] into individual word strings without + * materializing the full mnemonic as a String. + */ + private static List splitMnemonicChars(char[] chars) { + List words = new ArrayList<>(); + int start = 0; + for (int i = 0; i <= chars.length; i++) { + if (i == chars.length || chars[i] == ' ') { + if (i > start) { + words.add(new String(chars, start, i - start)); + } + start = i + 1; + } + } + return words; + } +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/CoinTicker.java b/src/main/java/io/xlite/daemon/app/net/CoinTicker.java new file mode 100644 index 0000000..def1ca8 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/CoinTicker.java @@ -0,0 +1,43 @@ +package io.xlite.daemon.app.net; + +import java.util.Arrays; +import java.util.List; + +public enum CoinTicker { + BLOCKNET, + + BITCOIN, + BITCOIN_CASH, + LITECOIN, + DASHCOIN, + DIGIBYTE, + DOGECOIN, + SYSCOIN, + PIVX, + RAVENCOIN, + UNOBTANIUM, + PKOIN + ; + + /** + * List of supported coins. + * @return Supported coins + */ + public static List coins() { + return Arrays.asList( + BLOCKNET, + BITCOIN, +// BITCOIN_CASH, - not support on backend + LITECOIN, + DASHCOIN, + DIGIBYTE, + DOGECOIN, + + SYSCOIN, + PIVX, + UNOBTANIUM, + PKOIN, + RAVENCOIN + ); + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/CoinTickerUtils.java b/src/main/java/io/xlite/daemon/app/net/CoinTickerUtils.java new file mode 100644 index 0000000..bf46f79 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/CoinTickerUtils.java @@ -0,0 +1,61 @@ +package io.xlite.daemon.app.net; + +import com.google.common.collect.HashBiMap; + +public class CoinTickerUtils { + private static HashBiMap tickers; + + static { + tickers = HashBiMap.create(); + + tickers.put(CoinTicker.BLOCKNET, "BLOCK"); + tickers.put(CoinTicker.BITCOIN, "BTC"); + tickers.put(CoinTicker.LITECOIN, "LTC"); + tickers.put(CoinTicker.DASHCOIN, "DASH"); + tickers.put(CoinTicker.DOGECOIN, "DOGE"); + tickers.put(CoinTicker.SYSCOIN, "SYS"); + tickers.put(CoinTicker.PIVX, "PIVX"); + + tickers.put(CoinTicker.DIGIBYTE, "DGB"); +// tickers.put(CoinTicker.BITCOIN_CASH, "BCH"); + tickers.put(CoinTicker.RAVENCOIN, "RVN"); + + tickers.put(CoinTicker.UNOBTANIUM, "UNO"); + tickers.put(CoinTicker.PKOIN, "PKOIN"); + + } + + public static String tickerToString(CoinTicker ticker) { + return tickers.get(ticker); + } + + public static CoinTicker stringToTicker(String string) { + return tickers.inverse().get(string); + } + + /** + * Active (mainnet, wallet-capable) tickers: the canonical supported + * list {@link CoinTicker#coins()}. There are no testnet variants; a + * delisted coin is excluded from {@code coins()} (its enum variant may + * linger, e.g. BITCOIN_CASH). To (re-)add one, restore its + * {@code coins()} entry, string mapping, supplement row and CoinInstance + * init case together (the source-of-truth test pins the set, mapping + * and supplement; wire the init case by hand). + */ + public static CoinTicker[] getActiveTickers() { + return CoinTicker.coins().toArray(new CoinTicker[0]); + } + + public static boolean tickerExists(String string) { + return tickers.inverse().containsKey(string); + } + + public static boolean isActiveTicker(CoinTicker ticker) { + for (CoinTicker t : getActiveTickers()) { + if (ticker == t) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/HasFeeParams.java b/src/main/java/io/xlite/daemon/app/net/HasFeeParams.java new file mode 100644 index 0000000..d6de60b --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/HasFeeParams.java @@ -0,0 +1,11 @@ +package io.xlite.daemon.app.net; + +public interface HasFeeParams { + default long getFeePerByte() { + throw new UnsupportedOperationException("getFeePerByte not implemented"); + } + + default long getMinTxFee() { + throw new UnsupportedOperationException("getMinTxFee not implemented"); + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/api/JSONRPCController.java b/src/main/java/io/xlite/daemon/app/net/api/JSONRPCController.java new file mode 100644 index 0000000..9b1b215 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/JSONRPCController.java @@ -0,0 +1,103 @@ +package io.xlite.daemon.app.net.api; + +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.util.ConfigHelper; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class JSONRPCController { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private static final ConcurrentHashMap servers = new ConcurrentHashMap<>(); + // Per-coin serialization of every map access: retire→create happens + // under the coin's lock, and plain lookups take it too, so no consumer + // can observe a mid-retirement server. Per-coin (not global) scope: + // a rebinding coin must not stall unrelated coins' event loops behind + // its multi-second release-wait. + private static final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + private static JSONRPCMasterServer masterServer = new JSONRPCMasterServer(new ConfigHelper("master").getMasterRpcPort()); + + public static JSONRPCMasterServer getMasterServer() { + return masterServer; + } + + public static JSONRPCServer getRPCServer(CoinInstance coinInstance) { + if (coinInstance == null || coinInstance.getRPCPort() == -1) { + throw new IllegalArgumentException("Bad coin instance"); + } + ReentrantLock lock = lockFor(coinInstance); + lock.lock(); + try { + return getOrCreateLocked(coinInstance); + } finally { + lock.unlock(); + } + } + + /** + * Retires the coin's current server and returns a freshly created one + * as a single step, serialized against every other controller access + * for this coin. Callers must use this instead of an unsynchronized + * remove+get pair: between retirement request and map release there + * is a bounded wait, and only this lock guarantees no consumer of + * this coin observes the retiring instance. + */ + public static JSONRPCServer rebindRPCServer(CoinInstance coinInstance) { + if (coinInstance == null || coinInstance.getRPCPort() == -1) { + throw new IllegalArgumentException("Bad coin instance"); + } + ReentrantLock lock = lockFor(coinInstance); + lock.lock(); + try { + removeLocked(coinInstance); + return getOrCreateLocked(coinInstance); + } finally { + lock.unlock(); + } + } + + private static ReentrantLock lockFor(CoinInstance coinInstance) { + return locks.computeIfAbsent(coinInstance, coin -> new ReentrantLock()); + } + + private static JSONRPCServer getOrCreateLocked(CoinInstance coinInstance) { + return servers.computeIfAbsent(coinInstance, + coin -> new JSONRPCServer(coin, coin.getRPCPort())); + } + + private static void removeLocked(CoinInstance coinInstance) { + JSONRPCServer server = servers.get(coinInstance); + if (server == null) + return; + + LOGGER.info("[rpc] rebind: retiring server " + server.lifecycleState()); + if (server.isAlive()) + server.deinit(); + + // Wait (bounded) for the old listener to release its socket so an + // immediate rebind on the same port cannot lose the race. Gate on + // real kernel bindability, not Netty close-completion — the fd can + // lag the closeFuture by ~1s. + server.awaitPortRelease(3000); + if (server.port() == coinInstance.getRPCPort()) { + if (!server.awaitPortBindable(5000)) { + LOGGER.warning("[rpc] rebind: port " + server.port() + + " still not bindable after release-wait; retrying at bind"); + } + } else { + // Config changed the port — the old port's bindability is + // irrelevant to where we are about to bind. + LOGGER.finer("[rpc] rebind: RPC port changed " + + server.port() + " -> " + coinInstance.getRPCPort() + + ", skipping old-port bindability wait"); + } + LOGGER.info("[rpc] rebind: after release-wait " + + server.lifecycleState()); + + servers.remove(coinInstance); + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/api/JSONRPCMasterServer.java b/src/main/java/io/xlite/daemon/app/net/api/JSONRPCMasterServer.java new file mode 100644 index 0000000..c2017b9 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/JSONRPCMasterServer.java @@ -0,0 +1,72 @@ +package io.xlite.daemon.app.net.api; + +import io.xlite.daemon.app.net.api.http.master.HTTPServerInitializer; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.util.concurrent.DefaultThreadFactory; + +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class JSONRPCMasterServer extends Thread { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private final int port; + private boolean stopping = false; + + private Channel channel; + private EventLoopGroup workerGroup; + + JSONRPCMasterServer(int port) { + setDaemon(true); + setName("rpc-master"); + this.port = port; + } + + public void run() { + workerGroup = new NioEventLoopGroup(2, + new DefaultThreadFactory("rpc-master", true)); + try { + LOGGER.info("[rpc] Starting master RPC server on port " + port + "."); + + ServerBootstrap bootstrap = new ServerBootstrap(); + bootstrap.group(workerGroup) + .option(ChannelOption.SO_BACKLOG, 128) + .option(ChannelOption.SO_REUSEADDR, true) + .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) + .channel(NioServerSocketChannel.class) + .childHandler(new HTTPServerInitializer()); + + channel = bootstrap.bind(port).sync().channel(); + + // Emitted only after the bind actually succeeded — readiness + // consumers (xlite-gui) anchor on this line, NOT on the + // pre-bind "Starting" line below. + LOGGER.info("[rpc-master] Master RPC server listening on port " + port + "."); + + channel.closeFuture().sync(); + } catch (Exception e) { + if (!stopping) { + LOGGER.warning("[rpc-master] Error during master RPC server operation" + e.getMessage()); + } + } + } + + public void deinit() { + stopping = true; + LOGGER.finer("[json-rpc-server] Interrupting server."); + + if (channel != null) { + channel.close(); + } + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/api/JSONRPCServer.java b/src/main/java/io/xlite/daemon/app/net/api/JSONRPCServer.java new file mode 100644 index 0000000..38fda86 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/JSONRPCServer.java @@ -0,0 +1,246 @@ +package io.xlite.daemon.app.net.api; + +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.net.api.http.server.HTTPServerInitializer; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.util.concurrent.DefaultThreadFactory; + +import java.net.BindException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.util.concurrent.TimeUnit; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class JSONRPCServer extends Thread { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private final CoinInstance coin; + private final int port; + private volatile boolean stopping = false; + private volatile boolean bound = false; + private volatile boolean bindFailed = false; + + private Channel channel; + private EventLoopGroup workerGroup; + + JSONRPCServer(CoinInstance coin, int port) { + this.coin = coin; + this.port = port; + // Servant thread: must never block JVM termination on its own + // liveness; lifecycle is governed by deinit()+bounded join. + setDaemon(true); + setName("rpc-coin-" + CoinTickerUtils.tickerToString(coin.getTicker())); + } + + int port() { + return port; + } + + public void run() { + // Daemon threads: the JVM must never be held hostage by an event + // loop draining its graceful-shutdown quiet period after SIGINT + // (DestroyJavaVM waited on these for the full grace window). + workerGroup = new NioEventLoopGroup(5, + new DefaultThreadFactory( + "rpc-coin-" + CoinTickerUtils.tickerToString(coin.getTicker()), true)); + try { + ServerBootstrap bootstrap = new ServerBootstrap(); + bootstrap.group(workerGroup) + .option(ChannelOption.SO_REUSEADDR, true) + .option(ChannelOption.SO_BACKLOG, 128) + .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) + .channel(NioServerSocketChannel.class) + .childHandler(new HTTPServerInitializer(coin)); + + // Kernel release of the previous listener can lag Netty's + // closeFuture by up to ~1s (observed: closeDone=true while the + // old LISTEN entry persisted). Retry rather than fail hard. + Channel boundChannel = null; + Exception lastBindError = null; + for (int attempt = 0; attempt < 20 && boundChannel == null && !stopping; attempt++) { + ChannelFuture bindFuture = null; + try { + bindFuture = bootstrap.bind(port); + bindFuture.sync(); + boundChannel = bindFuture.channel(); + } catch (InterruptedException e) { + // Stop requested mid-retry — preserve the flag and bail + // out instead of laundering it into a bind failure. The + // bind may still complete on the event loop, so close + // whatever channel it produced. + Thread.currentThread().interrupt(); + lastBindError = e; + if (bindFuture != null) + bindFuture.channel().close(); + break; + } catch (Exception e) { + lastBindError = e; + if (!isCauseBindException(e)) { + throw e; + } + try { + Thread.sleep(250); + } catch (InterruptedException ie) { + // Thrown from inside this catch, so the sibling + // clause cannot intercept it — preserve the flag + // and bail out here instead of laundering it. + Thread.currentThread().interrupt(); + lastBindError = ie; + break; + } + } + } + if (boundChannel == null) { + if (stopping) { + // Stop requested while retries were pending — a normal + // shutdown, not a bind failure. Do not stamp bindFailed. + LOGGER.finer("[rpc-server] bind retries abandoned: stopping"); + return; + } + throw lastBindError != null ? lastBindError + : new IllegalStateException("bind failed without exception"); + } + channel = boundChannel; + + // Emitted only after the bind actually succeeded. + LOGGER.info("[rpc] RPC server listening for " + + CoinTickerUtils.tickerToString(coin.getTicker()) + + " on port " + port + " at " + channel.localAddress() + "."); + bound = true; + + channel.closeFuture().sync(); + } catch (Exception e) { + bindFailed = true; + if (!stopping) { + LOGGER.warning("[rpc-server] Error during RPC server operation for " + CoinTickerUtils.tickerToString(coin.getTicker()) + e.getMessage()); + } + } + } + + private static boolean isCauseBindException(Throwable e) { + Throwable t = e; + while (t != null) { + if (t instanceof BindException) { + return true; + } + t = t.getCause(); + } + return false; + } + + /** + * Blocks until the listener has bound successfully, failed outright, + * or stop was requested — whichever comes first. + * @return true iff the port is bound and accepting. + */ + public boolean awaitBound(long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (bound || bindFailed || stopping) return bound; + try { + Thread.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return bound; + } + } + return bound; + } + + /** + * Best-effort wait until the previous listener has released its socket, + * so an immediate rebind on the same port cannot race the async close. + */ + public void awaitPortRelease(long timeoutMillis) { + if (channel != null) + channel.closeFuture().awaitUninterruptibly(timeoutMillis, TimeUnit.MILLISECONDS); + } + + /** + * Poll ACTUAL kernel bindability of the port. Netty's closeFuture can + * complete while the old LISTEN socket is still being torn down + * (observed ~1s lag), so close-completion alone is not a safe gate for + * an immediate rebind. Succeeds as soon as a probe listener can take + * either wildcard family; gives up after the timeout. + * @return true if the port became bindable in time + */ + public boolean awaitPortBindable(long timeoutMillis) { + return awaitPortBindable(port, timeoutMillis); + } + + /** + * Static form usable without an instance (also unit-testable). + */ + static boolean awaitPortBindable(int port, long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (portBindable(port)) { + return true; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return portBindable(port); + } + + /** + * Probe whether a listening socket could be opened on this port right now. + */ + static boolean portBindable(int port) { + for (String host : new String[]{"0.0.0.0", "::"}) { + try (ServerSocket probe = new ServerSocket()) { + probe.setReuseAddress(true); + probe.bind(new InetSocketAddress( + InetAddress.getByName(host), port), 1); + return true; + } catch (Exception e) { + // probe failed (held or blocked) — try the next family + } + } + return false; + } + + /** + * One-line lifecycle summary for diagnostics: thread state, bind flags + * and the recorded channel's open/done/local-address state. + */ + public String lifecycleState() { + Channel ch = channel; + return "srv=" + System.identityHashCode(this) + + " alive=" + isAlive() + + " stopping=" + stopping + + " bound=" + bound + + " bindFailed=" + bindFailed + + " channel=" + (ch == null ? "null" + : "open=" + ch.isOpen() + + " closeDone=" + ch.closeFuture().isDone() + + " local=" + ch.localAddress()); + } + + public void deinit() { + stopping = true; + LOGGER.finer("[json-rpc-server] Interrupting server."); + + if (channel != null) { + channel.close(); + } + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServer.java b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServer.java new file mode 100644 index 0000000..cb94be7 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServer.java @@ -0,0 +1,142 @@ +package io.xlite.daemon.app.net.api.http.client; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class EXRServer { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + private final String endpoint; + private final EXRWrapper wrapper; + private volatile boolean healthy; + private volatile long lastHealthCheck; + private final Set supportedCoins; + private volatile boolean capabilitiesProbed; + // Use centralized configuration constants + private static final int HEALTH_CHECK_INTERVAL_MS = HttpClientConfig.HEALTH_CHECK_INTERVAL_MS; + private static final int CAPABILITY_PROBE_TIMEOUT_MS = HttpClientConfig.CAPABILITY_PROBE_TIMEOUT_MS; + + public EXRServer(String endpoint) { + // Store endpoint with trailing slash for consistency + if (endpoint.endsWith("/")) { + this.endpoint = endpoint; + } else { + this.endpoint = endpoint + "/"; + } + // EXRWrapper expects endpoint without trailing slash + String wrapperEndpoint = this.endpoint.endsWith("/") ? + this.endpoint.substring(0, this.endpoint.length() - 1) : this.endpoint; + this.wrapper = new EXRWrapper(wrapperEndpoint); + this.healthy = true; + this.lastHealthCheck = 0; + this.supportedCoins = new HashSet<>(); + this.capabilitiesProbed = false; + } + + public boolean probeCapabilities() { + if (capabilitiesProbed) { + return true; + } + synchronized (this) { + if (capabilitiesProbed) { + return true; + } + if (!isHealthy()) { + return false; + } + try { + JsonElement root = wrapper.executeGet("heights"); + if (root == null || !root.isJsonObject()) { + // Unusable body — leave capabilitiesProbed false so the + // next probe cycle retries instead of pinning an empty set. + return false; + } + JsonObject result = root.getAsJsonObject(); + if (!result.has("result") || !result.get("result").isJsonObject()) { + return false; + } + JsonObject heights = result.getAsJsonObject("result"); + for (String coinName : heights.keySet()) { + JsonElement heightValue = heights.get(coinName); + if (heightValue.isJsonNull()) { + continue; + } + try { + CoinTicker coin = CoinTickerUtils.stringToTicker(coinName); + if (coin != null) { + supportedCoins.add(coin); + } + } catch (Exception e) { + LOGGER.finer("[exr-server] Failed to map coin " + coinName + ", " + e.getMessage()); + } + } + + capabilitiesProbed = true; + LOGGER.info("[exr-server] Probed capabilities for " + endpoint + ", supports: " + supportedCoins.size() + " coins: " + + supportedCoins.stream().map(CoinTickerUtils::tickerToString) + .reduce((a, b) -> a + ", " + b).orElse("none")); + return true; + } catch (Exception e) { + LOGGER.warning("[exr-server] Failed to probe capabilities for " + endpoint + ", " + e.getMessage()); + return false; + } + } + } + + public boolean isHealthy() { + long now = System.currentTimeMillis(); + if (now - lastHealthCheck < HEALTH_CHECK_INTERVAL_MS) { + return healthy; + } + try { + JsonElement result = wrapper.executeGet("heights"); + healthy = result != null && !result.isJsonNull(); + } catch (Exception e) { + healthy = false; + LOGGER.warning("[exr-server] Health check failed for " + endpoint + ", " + e.getMessage()); + } + lastHealthCheck = now; + return healthy; + } + + public JsonElement execute(String method, List params) { + if (!isHealthy()) { + return null; + } + // Server selection should have already filtered by coin support + // Remove redundant capability check to avoid race conditions + return wrapper.execute(method, params); + } + + public JsonElement executeGet(String method) { + return isHealthy() ? wrapper.executeGet(method) : null; + } + + public void close() { + wrapper.close(); + } + + public String getEndpoint() { + return endpoint; + } + + public boolean isCapabilitiesProbed() { + return capabilitiesProbed; + } + + public Set getSupportedCoins() { + return new HashSet<>(supportedCoins); + } + + public boolean hasCapability(CoinTicker coin) { + return supportedCoins.contains(coin); + } +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServerPool.java b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServerPool.java new file mode 100644 index 0000000..3b1c573 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServerPool.java @@ -0,0 +1,213 @@ +package io.xlite.daemon.app.net.api.http.client; + +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class EXRServerPool { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + private final List servers; + private final AtomicInteger currentIndex; + private final Map> coinToServersMap; + private final Map endpointToServerMap; + private volatile boolean capabilitiesProbed; + // Add synchronization lock for thread-safe map updates + private final Object mapUpdateLock = new Object(); + // Server selector interface for dependency injection + private final ServerSelector serverSelector; + + /** + * Constructor with dependency injection for flexible server selection. + * @param endpoints Comma-separated list of server endpoints + * @param serverSelector Server selection strategy implementation + */ + public EXRServerPool(String endpoints, ServerSelector serverSelector) { + this.servers = new CopyOnWriteArrayList<>(); + this.currentIndex = new AtomicInteger(0); + this.coinToServersMap = new ConcurrentHashMap<>(); + this.endpointToServerMap = new ConcurrentHashMap<>(); + this.capabilitiesProbed = false; + // Initialize server selector with dependency injection + this.serverSelector = serverSelector; + initializeServers(endpoints); + } + + /** + * Backward compatibility constructor using default round-robin selection. + * @param endpoints Comma-separated list of server endpoints + */ + public EXRServerPool(String endpoints) { + this(endpoints, new EXRServerSelectorImpl(new AtomicInteger(0))); + } + // Use centralized configuration constants + private static final int CAPABILITY_PROBE_TIMEOUT_MS = HttpClientConfig.CAPABILITY_PROBE_TIMEOUT_MS; + + public void startCapabilityProbing() { + if (capabilitiesProbed || servers.isEmpty()) { + return; + } + LOGGER.info("[exr-pool] Starting capability probing for " + servers.size() + " servers"); + // Best-effort network probes: daemon so a hung endpoint can never + // hold the JVM past shutdown. + Thread prober = new Thread(this::probeAllCapabilities, "EXR-Capability-Prober"); + prober.setDaemon(true); + prober.start(); + } + + public void probeAllCapabilities() { + if (capabilitiesProbed) { + return; + } + LOGGER.info("[exr-pool] Starting capability probing for " + servers.size() + " servers"); + // Probe each server concurrently + List probeThreads = new ArrayList<>(); + for (EXRServer server : servers) { + Thread t = new Thread(() -> { + try { + server.probeCapabilities(); + } catch (Exception e) { + LOGGER.warning("[exr-pool] Failed to probe server " + server.getEndpoint() + ", " + e.getMessage()); + } + }); + t.setDaemon(true); + probeThreads.add(t); + t.start(); + } + // Wait for all probes to complete + for (Thread t : probeThreads) { + try { + t.join(CAPABILITY_PROBE_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOGGER.warning("[exr-pool] Capability probing interrupted" + e.getMessage()); + } + } + // Build coin-to-servers mapping with synchronization + synchronized (mapUpdateLock) { + coinToServersMap.clear(); + for (EXRServer server : servers) { + for (CoinTicker coin : server.getSupportedCoins()) { + coinToServersMap.computeIfAbsent(coin, k -> new ArrayList<>()).add(server); + } + } + } + capabilitiesProbed = true; + logCapabilityResults(); + LOGGER.info("[exr-pool] Capability probing completed"); + } + + private void logCapabilityResults() { + LOGGER.info("[exr-pool] === EXR Server Capabilities ==="); + for (EXRServer server : servers) { + if (server.isCapabilitiesProbed()) { + String supportedCoins = server.getSupportedCoins().stream() + .map(coin -> CoinTickerUtils.tickerToString(coin)) + .reduce((a, b) -> a + ", " + b).orElse("none"); + LOGGER.info("[exr-pool] " + server.getEndpoint() + " supports: " + supportedCoins); + } else { + LOGGER.warning("[exr-pool] " + server.getEndpoint() + " capability probe failed"); + } + } + LOGGER.info("[exr-pool] === Coin Distribution ==="); + for (CoinTicker coin : CoinTicker.coins()) { + List supportingServers = coinToServersMap.get(coin); + if (supportingServers != null && !supportingServers.isEmpty()) { + LOGGER.info("[exr-pool] " + CoinTickerUtils.tickerToString(coin) + " supported by " + supportingServers.size() + " servers"); + } + } + } + + private void initializeServers(String endpoints) { + if (endpoints == null || endpoints.trim().isEmpty()) { + return; + } + String[] endpointArray = endpoints.split(","); + for (String endpoint : endpointArray) { + String trimmed = endpoint.trim(); + if (!trimmed.isEmpty()) { + EXRServer server = new EXRServer(trimmed); + servers.add(server); + endpointToServerMap.put(trimmed, server); + LOGGER.info("[exr-pool] Added EXR server: " + trimmed); + } + } + if (!servers.isEmpty()) { + LOGGER.info("[exr-pool] Created pool with " + servers.size() + " servers"); + } + } + + public EXRServer selectServer() { + return serverSelector.selectHealthyServer(servers); + } + + /** + * Select a server for a specific coin with proper error handling + * @param coin The coin to select a server for + * @return Selected server or null if none available + * @throws IllegalArgumentException if coin is null + */ + public EXRServer selectServerForCoin(CoinTicker coin) { + if (coin == null) { + throw new IllegalArgumentException("Coin cannot be null"); + } + if (!capabilitiesProbed) { + return null; // Wait for probing to complete + } + List supportingServers = coinToServersMap.get(coin); + return serverSelector.selectServerForCoin(supportingServers, coin); + } + + public Set getSupportedCoins() { + Set supported = new HashSet<>(); + for (EXRServer server : servers) { + supported.addAll(server.getSupportedCoins()); + } + return supported; + } + + public boolean hasServerForCoin(CoinTicker coin) { + if (!capabilitiesProbed) { + return !servers.isEmpty(); // Assume at least one server supports it + } + List supportingServers = coinToServersMap.get(coin); + return supportingServers != null && !supportingServers.isEmpty(); + } + + public void close() { + for (EXRServer server : servers) { + server.close(); + } + servers.clear(); + coinToServersMap.clear(); + endpointToServerMap.clear(); + } + + public List getServers() { + return new ArrayList<>(servers); + } + + public boolean isCapabilitiesProbed() { + return capabilitiesProbed; + } + + public int getServerCount() { + return servers.size(); + } + + public String getStatus() { + int healthyCount = 0; + for (EXRServer server : servers) { + if (server.isHealthy()) { + healthyCount++; + } + } + return healthyCount + "/" + servers.size() + " servers healthy"; + } +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServerSelector.java b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServerSelector.java new file mode 100644 index 0000000..0c0251f --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRServerSelector.java @@ -0,0 +1,173 @@ +package io.xlite.daemon.app.net.api.http.client; + +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +/** + * Server selection interface defining the contract for server selection strategies. + * This interface enables dependency injection and loose coupling between + * server pool management and server selection logic. + * + * Implementations can provide different selection algorithms such as: + * - Round-robin selection (default implementation) + * - Weighted selection based on server performance + * - Random selection + * - Health-based selection + * - Load-aware selection + */ +interface ServerSelector { + + /** + * Select a healthy server from the provided list using the selection strategy. + * The selection algorithm is implementation-specific and may use various criteria + * such as round-robin, random selection, or performance-based selection. + * + * @param servers List of servers to select from (must not be null) + * @return Selected healthy server or null if none available + * @throws IllegalArgumentException if servers list is null + */ + EXRServer selectHealthyServer(List servers); + + /** + * Select a server for a specific coin with proper error handling. + * This method ensures that the selected server supports the requested coin + * and is in a healthy state. + * + * @param supportingServers List of servers that support the coin (can be null) + * @param coin The coin to select a server for (must not be null) + * @return Selected server or null if none available + * @throws IllegalArgumentException if coin is null + */ + EXRServer selectServerForCoin(List supportingServers, CoinTicker coin); +} + +/** + * Default implementation of ServerSelector using round-robin selection strategy. + * This class provides the original server selection logic extracted from + * EXRServerPool to separate server selection concerns from pool management. + */ +class EXRServerSelectorImpl implements ServerSelector { + + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private final AtomicInteger currentIndex; + + /** + * Constructor for EXRServerSelectorImpl. + * @param currentIndex Shared atomic integer for round-robin selection + */ + public EXRServerSelectorImpl(AtomicInteger currentIndex) { + this.currentIndex = currentIndex; + } + + /** + * Select a healthy server from the provided list using round-robin. + * This method implements the same logic as EXRServerPool.selectServer() + * but extracts it into a separate utility class. + * + * @param servers List of servers to select from (must not be null) + * @return Selected healthy server or null if none available + * @throws IllegalArgumentException if servers list is null + */ + @Override + public EXRServer selectHealthyServer(List servers) { + if (servers.isEmpty()) { + LOGGER.warning("[server-selector] No servers available for selection"); + return null; + } + + // Try round-robin through healthy servers + int start = currentIndex.getAndIncrement() % servers.size(); + for (int i = 0; i < servers.size(); i++) { + int index = (start + i) % servers.size(); + EXRServer server = servers.get(index); + if (server.isHealthy()) { + LOGGER.fine("[server-selector] Selected server: " + server.getEndpoint()); + return server; + } + } + + LOGGER.warning("[server-selector] No healthy servers available"); + return null; // All servers unhealthy + } + + /** + * Select a server for a specific coin with proper error handling. + * This method implements coin-aware server selection logic, + * extracting it from EXRServerPool.selectServerForCoin(). + * + * @param supportingServers List of servers that support the coin (can be null) + * @param coin The coin to select a server for (must not be null) + * @return Selected server or null if none available + * @throws IllegalArgumentException if coin is null + */ + @Override + public EXRServer selectServerForCoin(List supportingServers, CoinTicker coin) { + if (supportingServers == null || supportingServers.isEmpty()) { + LOGGER.warning("[server-selector] NO EXR SERVERS SUPPORT COIN: " + + CoinTickerUtils.tickerToString(coin)); + return null; // FAIL - NO FALLBACK TO BASE_URL + } + + // Filter healthy servers + List healthyServers = getHealthySupportingServers(supportingServers); + + // Select from healthy servers + return selectFromHealthyServers(healthyServers, coin); + } + + /** + * Extract health filtering logic. + * This method filters the list to only include healthy servers. + * + * @param supportingServers List of servers that support the coin + * @return List of healthy servers that support the coin + */ + private List getHealthySupportingServers(List supportingServers) { + List healthyServers = new ArrayList<>(); + for (EXRServer server : supportingServers) { + if (server.isHealthy()) { + healthyServers.add(server); + } + } + return healthyServers; + } + + /** + * Extract server selection logic. + * This method selects a server from the healthy servers list + * using round-robin selection. + * + * @param healthyServers List of healthy servers + * @param coin The coin to select a server for + * @return Selected server or null if none available + */ + private EXRServer selectFromHealthyServers(List healthyServers, CoinTicker coin) { + if (healthyServers.isEmpty()) { + LOGGER.warning("[server-selector] NO HEALTHY EXR SERVERS FOR COIN: " + + CoinTickerUtils.tickerToString(coin)); + return null; + } + + int index = currentIndex.getAndIncrement() % healthyServers.size(); + EXRServer selectedServer = healthyServers.get(index); + + // Double-check that the selected server actually supports the coin + if (!selectedServer.hasCapability(coin)) { + LOGGER.severe("[server-selector] CRITICAL ERROR: Selected server " + + selectedServer.getEndpoint() + " does NOT support coin " + + CoinTickerUtils.tickerToString(coin)); + return null; + } + + return selectedServer; + } + +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRWrapper.java b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRWrapper.java new file mode 100644 index 0000000..2dcc68c --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/client/EXRWrapper.java @@ -0,0 +1,123 @@ +package io.xlite.daemon.app.net.api.http.client; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonSyntaxException; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class EXRWrapper { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + private final String exrEndpoint; + private final CloseableHttpClient client; + private final Gson gson; + // Constants for configuration + private static final String LOG_TAG = HttpClientConfig.LOG_TAG; + + public EXRWrapper(String exrEndpoint) { + this.exrEndpoint = exrEndpoint; + this.gson = new Gson(); + // Configure HTTP client with timeouts + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(HttpClientConfig.HTTP_TIMEOUT_MS) + .setConnectionRequestTimeout(HttpClientConfig.HTTP_TIMEOUT_MS) + .setSocketTimeout(HttpClientConfig.HTTP_TIMEOUT_MS) + .build(); + this.client = HttpClients.custom() + .setDefaultRequestConfig(config) + .build(); + } + + /** + * Execute an HTTP request and return the response body. + * Uses the same resource cleanup pattern as HTTPClient.executeHttpRequest() + * @param request The HTTP request to execute + * @param operation Description of the operation for logging + * @return Response body string or null on error + */ + private String executeHttpRequest(HttpRequestBase request, String operation) { + return HttpUtils.executeHttpRequest(client, request, operation); + } + + /** + * Parse a response body verbatim. No synthetic wrapping is applied: + * arrays and primitives are returned as-is so upstream payload shapes + * survive unchanged (callers normalize where needed). + * @param responseBody The raw response body + * @return Parsed JsonElement or null if the body was not valid JSON + */ + private JsonElement parseBody(String responseBody) { + try { + return gson.fromJson(responseBody, JsonElement.class); + } catch (JsonSyntaxException e) { + LOGGER.warning(LOG_TAG + " invalid JSON response - " + e.getMessage()); + return null; + } + } + + /** + * Execute a POST request to an EXR endpoint. + * Transforms the method and params into EXR format. + * + * @param method The method name (e.g., "getblockhash") + * @param params The parameters as a List of Objects + * @return Parsed response element or null on error + */ + public JsonElement execute(String method, List params) { + String endpoint = exrEndpoint + "/xrs/" + method; + String currency = params.isEmpty() || !(params.get(0) instanceof String) ? + "unknown" : (String) params.get(0); + String requestBody = gson.toJson(params); + HttpPost httpPost = new HttpPost(); + httpPost.setURI(URI.create(endpoint)); + httpPost.setHeader("Content-Type", "application/json"); + try { + httpPost.setEntity(new StringEntity(requestBody)); + String responseBody = executeHttpRequest(httpPost, "execute POST for " + method + " " + currency); + return responseBody != null ? parseBody(responseBody) : null; + } catch (IOException e) { + LOGGER.warning(LOG_TAG + " execute POST failed for " + method + " " + currency + " endpoint: " + endpoint + ", " + e.getMessage()); + return null; + } finally { + httpPost.reset(); + } + } + + /** + * Execute a GET request to an EXR endpoint. + * + * @param method The method name (e.g., "fees", "heights") + * @return Parsed response element or null on error + */ + public JsonElement executeGet(String method) { + String endpoint = exrEndpoint + "/xrs/" + method; + HttpGet httpGet = new HttpGet(endpoint); + httpGet.setHeader("Content-Type", "application/json"); + String responseBody = executeHttpRequest(httpGet, "execute GET for method " + method); + return responseBody != null ? parseBody(responseBody) : null; + } + + /** + * Close the HTTP client resources. + */ + public void close() { + try { + client.close(); + } catch (IOException e) { + LOGGER.warning(LOG_TAG + " Failed to close HTTP client" + e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/net/api/http/client/HTTPClient.java b/src/main/java/io/xlite/daemon/app/net/api/http/client/HTTPClient.java similarity index 54% rename from src/main/java/io/cloudchains/app/net/api/http/client/HTTPClient.java rename to src/main/java/io/xlite/daemon/app/net/api/http/client/HTTPClient.java index e17dadc..b904039 100644 --- a/src/main/java/io/cloudchains/app/net/api/http/client/HTTPClient.java +++ b/src/main/java/io/xlite/daemon/app/net/api/http/client/HTTPClient.java @@ -1,56 +1,53 @@ -package io.cloudchains.app.net.api.http.client; +package io.xlite.daemon.app.net.api.http.client; import com.google.common.collect.Lists; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; import com.subgraph.orchid.encoders.Hex; -import io.cloudchains.app.App; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.api.http.client.EXRWrapper; -import io.cloudchains.app.util.AddressBalance; -import io.cloudchains.app.util.UTXO; -import io.cloudchains.app.util.history.Transaction; +import io.xlite.daemon.app.App; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.util.AddressBalance; +import io.xlite.daemon.app.util.Sats; +import io.xlite.daemon.app.util.UTXO; +import io.xlite.daemon.app.util.history.Transaction; import org.apache.http.Header; -import org.apache.http.HttpEntity; import org.apache.http.HttpHeaders; -import org.apache.http.HttpResponse; import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.methods.*; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.config.RegistryBuilder; import org.apache.http.conn.socket.ConnectionSocketFactory; import org.apache.http.conn.socket.PlainConnectionSocketFactory; -import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.message.BasicHeader; -import org.apache.http.ssl.SSLContextBuilder; -import org.apache.http.util.EntityUtils; -import org.bitcoinj.core.Address; +import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.script.Script; import org.bitcoinj.script.ScriptBuilder; import org.json.JSONArray; import org.json.JSONObject; -import javax.net.ssl.SSLContext; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URI; -import java.security.KeyManagementException; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; @@ -61,7 +58,7 @@ public class HTTPClient { private CloseableHttpClient client; private ConcurrentHashMap lastFetchTimes; private int logCount = 0; - + /** * Helper method to wait for EXR capabilities to be probed with a timeout. * @param timeoutMs Maximum time to wait in milliseconds @@ -71,30 +68,30 @@ private boolean waitForCapabilities(int timeoutMs) { if (!useEXR()) { return false; } - + if (App.exrServerPool.isCapabilitiesProbed()) { return true; } - - LOGGER.log(Level.FINE, "[httpclient] Waiting for EXR capabilities to be probed (timeout: " + timeoutMs + "ms)"); - + + LOGGER.fine("[httpclient] Waiting for EXR capabilities to be probed (timeout: " + timeoutMs + "ms)"); + int waitTime = 0; while (!App.exrServerPool.isCapabilitiesProbed() && waitTime < timeoutMs) { try { - Thread.sleep(100); - waitTime += 100; + Thread.sleep(HttpClientConfig.CAPABILITY_PROBE_WAIT_INTERVAL_MS); + waitTime += HttpClientConfig.CAPABILITY_PROBE_WAIT_INTERVAL_MS; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - LOGGER.log(Level.WARNING, "[httpclient] Waiting for capabilities was interrupted"); + LOGGER.warning("[httpclient] Waiting for capabilities was interrupted"); return false; } } - + boolean probed = App.exrServerPool.isCapabilitiesProbed(); - LOGGER.log(Level.FINE, "[httpclient] EXR capabilities " + - (probed ? "probed successfully" : "still not probed") + - " after waiting " + waitTime + "ms"); - + LOGGER.fine("[httpclient] EXR capabilities " + + (probed ? "probed successfully" : "still not probed") + + " after waiting " + waitTime + "ms"); + return probed; } @@ -102,7 +99,7 @@ private boolean waitForCapabilities(int timeoutMs) { * Helper method to check if EXR pool is available and configured. * @return true if EXR pool is configured, false otherwise */ - private boolean useEXR() { + boolean useEXR() { return App.exrServerPool != null && App.exrServerPool.getServerCount() > 0; } @@ -111,10 +108,10 @@ private boolean useEXR() { * @param endpoint The API endpoint * @return true if EXR should be used, false otherwise */ - private boolean shouldUseEXR(String endpoint) { + boolean shouldUseEXR(String endpoint) { return useEXR() && (endpoint.equals("/fees") || - endpoint.equals("/height") || - endpoint.equals("/")); + endpoint.equals("/height") || + endpoint.equals("/")); } /** @@ -124,17 +121,17 @@ private boolean shouldUseEXR(String endpoint) { private EXRServer getEXRServer() { return useEXR() ? App.exrServerPool.selectServer() : null; } - + /** * Convert JsonArray to List for EXR execution * @param exrParams JsonArray of parameters * @return List of parameters */ - private java.util.List convertParams(com.google.gson.JsonArray exrParams) { - java.util.List paramList = new java.util.ArrayList<>(); - for (com.google.gson.JsonElement element : exrParams) { + private List convertParams(JsonArray exrParams) { + List paramList = new ArrayList<>(); + for (JsonElement element : exrParams) { if (element.isJsonPrimitive()) { - com.google.gson.JsonPrimitive primitive = element.getAsJsonPrimitive(); + JsonPrimitive primitive = element.getAsJsonPrimitive(); if (primitive.isString()) { paramList.add(primitive.getAsString()); } else if (primitive.isNumber()) { @@ -156,29 +153,7 @@ private java.util.List convertParams(com.google.gson.JsonArray exrParams * @return Response string or null on error */ private String executeHttpRequest(T request) { - CloseableHttpResponse response = null; - try { - response = client.execute(request); - if (validateResponse(response)) { - HttpEntity entity = response.getEntity(); - String result = EntityUtils.toString(entity); - EntityUtils.consume(entity); - return result; - } - return null; - } catch (java.io.IOException e) { - LOGGER.log(Level.WARNING, "HTTP request failed: " + e.toString()); - return null; - } finally { - request.reset(); - if (response != null) { - try { - response.close(); - } catch (java.io.IOException e) { - LOGGER.log(Level.WARNING, "Failed to close response: " + e.toString()); - } - } - } + return HttpUtils.executeHttpRequest(client, request, "HTTP request"); } /** @@ -188,7 +163,7 @@ private String executeHttpRequest(T request) { */ private String executeGetRequest(String endpoint) { HttpGet httpGet = new HttpGet(App.BASE_URL + endpoint); - return executeHttpRequest(httpGet); + return HttpUtils.executeHttpRequest(client, httpGet, "GET " + endpoint); } /** @@ -197,45 +172,59 @@ private String executeGetRequest(String endpoint) { * @param params The parameters to POST * @return Response string or null on error */ - private String executePostRequest(String endpoint, com.google.gson.JsonObject params) { + private String executePostRequest(String endpoint, JsonObject params) { HttpPost httpPost = new HttpPost(); - httpPost.setURI(java.net.URI.create(App.BASE_URL + endpoint)); + httpPost.setURI(URI.create(App.BASE_URL + endpoint)); try { httpPost.setEntity(new StringEntity(params.toString())); - } catch (java.io.UnsupportedEncodingException e) { - LOGGER.log(Level.WARNING, "executePostRequest failed to set entity " + endpoint + " err: " + e.toString()); + } catch (UnsupportedEncodingException e) { + LOGGER.warning("executePostRequest failed to set entity " + endpoint + " err: " + e.getMessage()); httpPost.reset(); return null; } - return executeHttpRequest(httpPost); + return HttpUtils.executeHttpRequest(client, httpPost, describePost(endpoint, params)); } - public HTTPClient(int maximumSockets) { - SSLContext sslContext = null; - lastFetchTimes = new ConcurrentHashMap<>(); - + /** + * Builds a context-rich operation label from what's already in hand: + * every legacy POST caller sends {method, params:[first, ...]} where + * the first param is the ticker for coin-first-param methods. Never + * throws: a description must not break the request it describes. + */ + private static String describePost(String endpoint, JsonObject params) { + String method = "?"; + String first = ""; try { - sslContext = new SSLContextBuilder() - .loadTrustMaterial(null, (x509CertChain, authType) -> true) - .build(); - } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) { - e.printStackTrace(); + if (params != null && params.has("method") && params.get("method").isJsonPrimitive()) { + method = params.get("method").getAsString(); + } + if (params != null && params.has("params") && params.get("params").isJsonArray()) { + JsonArray arr = params.getAsJsonArray("params"); + if (arr.size() > 0 && arr.get(0).isJsonPrimitive()) { + first = " " + arr.get(0).getAsString(); + } + } + } catch (Exception ignored) { + // Fall through with whatever was extracted; worst case "?". } + return "POST " + endpoint + " [" + method + first + "]"; + } + + public HTTPClient(int maximumSockets) { + lastFetchTimes = new ConcurrentHashMap<>(); Header header = new BasicHeader(HttpHeaders.CONTENT_TYPE, "application/json"); List
headers = Lists.newArrayList(header); RequestConfig.Builder requestBuilder = RequestConfig.custom(); - requestBuilder.setConnectTimeout(30000); - requestBuilder.setConnectionRequestTimeout(30000); - requestBuilder.setSocketTimeout(30000); + requestBuilder.setConnectTimeout(HttpClientConfig.HTTP_TIMEOUT_MS); + requestBuilder.setConnectionRequestTimeout(HttpClientConfig.HTTP_TIMEOUT_MS); + requestBuilder.setSocketTimeout(HttpClientConfig.HTTP_TIMEOUT_MS); - assert sslContext != null; PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager( RegistryBuilder.create() .register("http", PlainConnectionSocketFactory.INSTANCE) - .register("https", new SSLConnectionSocketFactory(sslContext, - NoopHostnameVerifier.INSTANCE)) + .register("https", SSLConnectionSocketFactory.getSystemSocketFactory()) .build() ); connectionManager.setDefaultMaxPerRoute(maximumSockets); @@ -243,8 +232,6 @@ public HTTPClient(int maximumSockets) { client = HttpClients.custom() .setDefaultHeaders(headers) - .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) - .setSSLContext(sslContext) .setConnectionManager(connectionManager) .setDefaultRequestConfig(requestBuilder.build()) .build(); @@ -254,7 +241,7 @@ public void close() { try { client.close(); } catch (IOException e) { - e.printStackTrace(); + LOGGER.warning("[httpclient] Failed to close HTTP client" + e.getMessage()); } } @@ -265,38 +252,37 @@ public void close() { */ private String aggregateEXRResponse(String method) { // Aggregate from ALL EXR servers - com.google.gson.JsonObject aggregatedResult = new com.google.gson.JsonObject(); - com.google.gson.JsonArray aggregatedErrors = new com.google.gson.JsonArray(); - + JsonObject aggregatedResult = new JsonObject(); + JsonArray aggregatedErrors = new JsonArray(); + for (EXRServer server : App.exrServerPool.getServers()) { if (!server.isHealthy()) { continue; } - + try { - com.google.gson.JsonObject result = server.executeGet(method); - if (result != null && result.has("result")) { - com.google.gson.JsonElement serverResult = result.get("result"); - - if (serverResult.isJsonObject()) { - com.google.gson.JsonObject serverObj = serverResult.getAsJsonObject(); + JsonElement response = server.executeGet(method); + if (response != null && response.isJsonObject()) { + JsonObject result = response.getAsJsonObject(); + if (result.has("result") && result.get("result").isJsonObject()) { + JsonObject serverObj = result.getAsJsonObject("result"); for (String key : serverObj.keySet()) { if (!aggregatedResult.has(key)) { aggregatedResult.add(key, serverObj.get(key)); } } } - server.probeCapabilities(); } + server.probeCapabilities(); } catch (Exception e) { aggregatedErrors.add("Failed " + method + " from " + server.getEndpoint()); } } - - com.google.gson.JsonObject finalResult = new com.google.gson.JsonObject(); + + JsonObject finalResult = new JsonObject(); finalResult.add("result", aggregatedResult); finalResult.add("errors", aggregatedErrors); - + return finalResult.toString(); } @@ -310,98 +296,128 @@ private String executeEXRGet(String endpoint) { return aggregateEXRResponse(method); } + /** + * Extract the coin ticker from the first parameter of an EXR request. + * @param exrParams The parameter array + * @return Ticker or null if the first parameter is not a known coin + */ + static CoinTicker extractCoin(JsonArray exrParams) { + if (exrParams.size() == 0 || !exrParams.get(0).isJsonPrimitive() + || !exrParams.get(0).getAsJsonPrimitive().isString()) { + return null; + } + return CoinTickerUtils.stringToTicker(exrParams.get(0).getAsString()); + } + /** * Execute POST request with EXR coin-aware routing * @param endpoint The endpoint to POST to * @param params The parameters to POST - * @return Response from appropriate EXR server + * @return Response from appropriate EXR server, or null (FAIL - NO FALLBACK TO BASE_URL) */ - private String executeEXRPost(String endpoint, com.google.gson.JsonObject params) { - if (params.has("method") && params.has("params")) { - String method = params.get("method").getAsString(); - com.google.gson.JsonArray exrParams = params.getAsJsonArray("params"); - - // Extract coin from first parameter - io.cloudchains.app.net.CoinTicker coin = null; - if (exrParams.size() > 0) { - String coinString = exrParams.get(0).getAsString(); - coin = io.cloudchains.app.net.CoinTickerUtils.stringToTicker(coinString); - if (coin == null) { - // Log the failed coin extraction for debugging - LOGGER.log(Level.WARNING, "[httpclient] Failed to extract coin from parameter: " + coinString); - // Not a coin-specific request - } - } - - EXRServer server = null; - - // Route ONLY to EXR servers that support this coin - if (coin != null) { - // Wait for capabilities to be probed if not already done - if (!App.exrServerPool.isCapabilitiesProbed()) { - LOGGER.log(Level.FINE, "[httpclient] Waiting for EXR capabilities to be probed for coin: " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - if (!waitForCapabilities(10000)) { // Wait up to 10 seconds - LOGGER.log(Level.WARNING, "[httpclient] EXR capabilities not probed yet for coin: " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - return null; // FAIL - NO FALLBACK TO BASE_URL - } - } - - if (App.exrServerPool.isCapabilitiesProbed()) { - server = App.exrServerPool.selectServerForCoin(coin); - // LOGGER.log(Level.INFO, "[httpclient] DEBUG: selectServerForCoin returned: " + - // (server != null ? server.getEndpoint() : "null")); - - if (server == null) { - LOGGER.log(Level.SEVERE, "[httpclient] NO EXR SERVER SUPPORTS COIN: " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - return null; // FAIL - NO FALLBACK TO BASE_URL - } else { - LOGGER.log(Level.INFO, "[httpclient] DEBUG: Selected server " + server.getEndpoint() + - " for coin " + io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin) + - ", method: " + method); - } - } else { - // Capabilities still not probed after waiting - LOGGER.log(Level.WARNING, "[httpclient] EXR capabilities not probed yet for coin: " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - return null; // FAIL - NO FALLBACK TO BASE_URL - } - } else { - // Use round-robin for non-coin-specific requests - // But if we have a coin, we MUST use coin-aware selection - if (coin != null) { - server = App.exrServerPool.selectServerForCoin(coin); - if (server == null) { - LOGGER.log(Level.SEVERE, "[httpclient] NO EXR SERVER SUPPORTS COIN: " + - io.cloudchains.app.net.CoinTickerUtils.tickerToString(coin)); - return null; // FAIL - NO FALLBACK TO BASE_URL - } - } else { - // No coin extracted - this should not happen for coin-specific requests - LOGGER.log(Level.SEVERE, "[httpclient] Cannot route request: coin extraction failed"); - return null; // FAIL instead of using wrong server - } - } - - if (server != null) { - java.util.List paramList = convertParams(exrParams); - com.google.gson.JsonObject result = server.execute(method, paramList); - if (result != null) { - // Handle wrapped responses from EXR wrapper - // If the result has a "result" field, extract it to maintain backward compatibility - if (result.has("result")) { - com.google.gson.JsonElement resultElement = result.get("result"); - if (!resultElement.isJsonNull()) { - return resultElement.toString(); - } + private String executeEXRPost(String endpoint, JsonObject params) { + if (!params.has("method") || !params.has("params")) { + return null; + } + String method = params.get("method").getAsString(); + JsonArray exrParams = params.getAsJsonArray("params"); + + CoinTicker coin = extractCoin(exrParams); + if (coin == null) { + LOGGER.severe("[httpclient] Cannot route request: no coin extracted from first parameter for method " + method); + return null; // FAIL instead of using wrong server + } + + if (!App.exrServerPool.isCapabilitiesProbed() + && !waitForCapabilities(HttpClientConfig.CAPABILITY_PROBE_WAIT_TIMEOUT_MS)) { + LOGGER.warning("[httpclient] EXR capabilities not probed yet for coin: " + + CoinTickerUtils.tickerToString(coin)); + return null; // FAIL - NO FALLBACK TO BASE_URL + } + + EXRServer server = App.exrServerPool.selectServerForCoin(coin); + if (server == null) { + LOGGER.warning("[httpclient] NO EXR SERVER SUPPORTS COIN: " + + CoinTickerUtils.tickerToString(coin)); + return null; // FAIL - NO FALLBACK TO BASE_URL + } + LOGGER.info("[httpclient] Routed " + method + " " + + CoinTickerUtils.tickerToString(coin) + " to " + server.getEndpoint()); + + List paramList = convertParams(exrParams); + JsonElement response = server.execute(method, paramList); + if (response == null) { + LOGGER.warning("[httpclient] EXR request failed for " + method + " via " + server.getEndpoint()); + return null; + } + return normalizeEXRResponse(response); + } + + /** + * Normalize a parsed EXR response body into the text form consumers expect. + * Upstream payload shapes are preserved verbatim: JSON-RPC style envelopes, + * bare objects and bare arrays all pass through unchanged. A JSON text + * delivered as a string-typed {@code result} member is unwrapped exactly + * once; scalar string results (e.g. broadcast txids) are kept wrapped. + * @param response Parsed response element + * @return Response text or null if the element carried no content + */ + static String normalizeEXRResponse(JsonElement response) { + if (response == null || response.isJsonNull()) { + return null; + } + if (response.isJsonObject()) { + JsonObject obj = response.getAsJsonObject(); + if (obj.has("result") && obj.get("result").isJsonPrimitive() + && obj.get("result").getAsJsonPrimitive().isString()) { + try { + JsonElement inner = JsonParser.parseString(obj.get("result").getAsString()); + if (inner.isJsonObject() || inner.isJsonArray()) { + return inner.toString(); } - return result.toString(); + } catch (JsonSyntaxException e) { + // Not JSON text — pass the envelope through unchanged } } + return obj.toString(); + } + return response.toString(); + } + + /** + * Parse an upstream JSON-RPC style envelope ({@code {"result":…,"error":…}}), + * returning null (with a logged reason) when the payload is absent, empty, + * an error, malformed, or not an object. Consumers receive the full envelope + * so they can read the {@code result} member exactly as with the legacy backend. + * @param res Raw response text + * @param opName Operation description for log messages + * @return Envelope object or null on failure + */ + static JsonObject parseEnvelope(String res, String opName) { + if (res == null) { + return null; + } + JsonElement parsed; + try { + parsed = JsonParser.parseString(res); + } catch (JsonSyntaxException e) { + LOGGER.warning("[httpclient] " + opName + " invalid upstream JSON - " + e.getMessage()); + return null; + } + if (!parsed.isJsonObject()) { + LOGGER.warning("[httpclient] " + opName + " unexpected upstream payload shape"); + return null; + } + JsonObject obj = parsed.getAsJsonObject(); + if (obj.has("error") && !obj.get("error").isJsonNull()) { + LOGGER.warning("[httpclient] " + opName + " upstream error - " + obj.get("error").toString()); + return null; + } + if (!obj.has("result") || obj.get("result").isJsonNull()) { + LOGGER.warning("[httpclient] " + opName + " empty upstream result"); + return null; } - return null; + return obj; } /** @@ -410,7 +426,7 @@ private String executeEXRPost(String endpoint, com.google.gson.JsonObject params * @param params Parameters for POST requests, null for GET * @return Response string or null on error */ - private String executeRequest(String endpoint, com.google.gson.JsonObject params) { + String executeRequest(String endpoint, JsonObject params) { // When EXR is configured, ONLY use EXR - NO fallback to BASE_URL if (shouldUseEXR(endpoint)) { if (params == null) { @@ -421,7 +437,7 @@ private String executeRequest(String endpoint, com.google.gson.JsonObject params return executeEXRPost(endpoint, params); } } - + // ONLY fall back to BASE_URL when EXR is NOT configured if (!useEXR()) { if (params == null) { @@ -432,16 +448,8 @@ private String executeRequest(String endpoint, com.google.gson.JsonObject params return executePostRequest(endpoint, params); } } - - return null; // EXR configured but no valid response - } - - private String doGet(String endpoint) { - return executeRequest(endpoint, null); - } - private String doPost(String endpoint, com.google.gson.JsonObject params) { - return executeRequest(endpoint, params); + return null; // EXR configured but no valid response } /** @@ -462,12 +470,11 @@ public JsonArray getUtxosUncached(CoinTicker coinTicker, String[] addresses) { JsonObject params = new JsonObject(); params.addProperty("method", "getutxos"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getUtxosUncached " + coinInstance.getTicker() + " " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getUtxosUncached " + coinInstance.getTicker() + " " + res); if (res == null) { - LOGGER.log(Level.WARNING, "[httpclient] getUtxosUncached " + coinInstance.getTicker() + " null post result"); return null; } @@ -477,14 +484,14 @@ public JsonArray getUtxosUncached(CoinTicker coinTicker, String[] addresses) { jsonObject = new JSONObject(res); utxoArr = jsonObject.getJSONArray("utxos"); } catch (Exception e) { - e.printStackTrace(); + LOGGER.warning("[httpclient] getUtxosUncached " + coinInstance.getTicker() + " parse error - " + e.getMessage()); } if (jsonObject == null || utxoArr == null) { if (jsonObject == null) - LOGGER.log(Level.WARNING, "[httpclient] getUtxosUncached " + coinInstance.getTicker() + " null jsonObject"); + LOGGER.warning("[httpclient] getUtxosUncached " + coinInstance.getTicker() + " null jsonObject"); if (utxoArr == null) - LOGGER.log(Level.WARNING, "[httpclient] getUtxosUncached " + coinInstance.getTicker() + " null utxoArr"); + LOGGER.warning("[httpclient] getUtxosUncached " + coinInstance.getTicker() + " null utxoArr"); return null; } @@ -499,7 +506,7 @@ public JsonArray getUtxosUncached(CoinTicker coinTicker, String[] addresses) { String address = utxoArr.getJSONObject(i).getString("address"); utxoJSON.addProperty("address", address); - Address addr = Address.fromBase58(coinInstance.getNetworkParameters(), address); + LegacyAddress addr = LegacyAddress.fromBase58(coinInstance.getNetworkParameters(), address); Script script = ScriptBuilder.createOutputScript(addr); utxoJSON.addProperty("scriptPubKey", new String(Hex.encode(script.getProgram()))); @@ -517,6 +524,15 @@ public JsonArray getUtxosUncached(CoinTicker coinTicker, String[] addresses) { return utxoList; } + /** + * Whole-coin doubles (backend JSON) to integer base units. Must round to + * nearest: truncating the binary representation error loses satoshis + * (e.g. 0.00050001 becomes 50000). + */ + static long satsFromWholeCoins(double whole) { + return Sats.fromWholeCoins(whole); + } + /** * Returns all utxos. * @param coinTicker Fetch utxos from this coin @@ -532,7 +548,7 @@ public JsonArray getUtxos(CoinTicker coinTicker, int expiry) { ArrayList utxoParams = coinInstance.getUTXOParams(); if (utxoParams.size() == 0) { - LOGGER.log(Level.WARNING, "[httpclient] getUtxos " + coinInstance.getTicker() + " null param size"); + LOGGER.warning("[httpclient] getUtxos " + coinInstance.getTicker() + " null param size"); return null; } @@ -541,12 +557,11 @@ public JsonArray getUtxos(CoinTicker coinTicker, int expiry) { JsonObject params = new JsonObject(); params.addProperty("method", "getutxos"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getUtxos " + coinInstance.getTicker() + " " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getUtxos " + coinInstance.getTicker() + " " + res); if (res == null) { - LOGGER.log(Level.WARNING, "[httpclient] getUtxos " + coinInstance.getTicker() + " null post result"); return null; } @@ -556,29 +571,50 @@ public JsonArray getUtxos(CoinTicker coinTicker, int expiry) { jsonObject = new JSONObject(res); utxoArr = jsonObject.getJSONArray("utxos"); } catch (Exception e) { - e.printStackTrace(); + LOGGER.warning("[httpclient] getUtxos " + coinInstance.getTicker() + " parse error - " + e.getMessage()); } if (jsonObject == null || utxoArr == null) { if (jsonObject == null) - LOGGER.log(Level.WARNING, "[httpclient] getUtxos " + coinInstance.getTicker() + " null jsonObject"); + LOGGER.warning("[httpclient] getUtxos " + coinInstance.getTicker() + " null jsonObject"); if (utxoArr == null) - LOGGER.log(Level.WARNING, "[httpclient] getUtxos " + coinInstance.getTicker() + " null utxoArr"); + LOGGER.warning("[httpclient] getUtxos " + coinInstance.getTicker() + " null utxoArr"); return null; } List utxoList = new ArrayList<>(); + Set returnedAddresses = new HashSet<>(); + // Backend-declared per-address failures ("errors" array the adapter + // attaches): such addresses are treated as omitted entirely — their + // rows are neither added below nor kept stored (see eviction). + Set failedAddresses = failedAddresses(jsonObject); for (int i = 0; i < utxoArr.length(); i++) { + String rowAddress = utxoArr.getJSONObject(i).getString("address"); + if (failedAddresses.contains(rowAddress)) { + continue; + } UTXO utxo = new UTXO(coinTicker, - utxoArr.getJSONObject(i).getString("address"), + rowAddress, utxoArr.getJSONObject(i).getString("txhash"), utxoArr.getJSONObject(i).getInt("vout"), utxoArr.getJSONObject(i).getInt("block_number"), - (long) Math.floor(utxoArr.getJSONObject(i).getDouble("value") * 100000000.0)); + satsFromWholeCoins(utxoArr.getJSONObject(i).getDouble("value"))); utxoList.add(utxo); + returnedAddresses.add(rowAddress); } + // Evict rows for requested-but-backend-omitted addresses. A successful + // response that omits a tracked address (or flags it in the backend's + // "errors" array) means the backend holds no unspent outputs for it — + // any stored rows are confirmed-stale (a lagging view re-serves dead + // outputs with live-looking confirmations otherwise). processUtxos + // below only clears addresses PRESENT in the response, so without this + // an omitted address keeps its rows forever. Runs on parsed-success + // only: null/parse-error responses return above with stored rows + // untouched (an outage must never wipe the wallet). + evictOmittedAddresses(coinInstance, utxoParams, returnedAddresses); + // Update last fetch time lastFetchTimes.put(lastFetchTimesKey(coinTicker, "getUtxos"), currentTime); @@ -586,33 +622,76 @@ public JsonArray getUtxos(CoinTicker coinTicker, int expiry) { return coinInstance.getAllUTXOS(); } - public void getAllFees() { - String res = doGet("/fees"); - - if (res == null) return; - - JsonObject result = new Gson().fromJson(res, JsonObject.class); - JsonObject fees = result.get("result").getAsJsonObject(); - - for (CoinTicker coinTicker : CoinTicker.coins()) { - CoinInstance coinInstance = CoinInstance.getInstance(coinTicker); - String ticker = CoinTickerUtils.tickerToString(coinTicker); + /** + * Reads the backend-declared per-address failures from a getutxos + * response ("errors" array of {address, message} items the adapter + * attaches; absent/null on clean responses). Never throws: an + * unparseable array degrades to a (possibly partial) set (the omission + * diff below still applies). + */ + Set failedAddresses(JSONObject response) { + Set failed = new HashSet<>(); + try { + JSONArray errors = response.optJSONArray("errors"); + if (errors != null) { + for (int i = 0; i < errors.length(); i++) { + String addr = errors.getJSONObject(i).optString("address", null); + if (addr != null) { + failed.add(addr); + } + } + } + } catch (Exception e) { + LOGGER.warning("[httpclient] failedAddresses unparseable errors array - " + e.getMessage()); + } + return failed; + } - if (!fees.keySet().contains(ticker) || fees.get(ticker).isJsonNull()) { - coinInstance.incrementUpdateFailures(); + /** + * Clears stored UTXO rows for tracked addresses the backend omitted from + * a successful getutxos response. requestedParams is the + * [ticker, "[addr...]"] list built by CoinInstance.getUTXOParams (the + * second element is a stringified JSONArray); returned holds the + * addresses present in the response. Backend-declared failures never + * reach returned (their rows are skipped before recording above), so a + * requested address missing from returned is confirmed to hold nothing. + * Unknown/non-tracked addresses are ignored (processUtxos + * warns on those itself). clearUtxos spares spent-marked rows, so only + * live-looking stale rows are dropped. + */ + void evictOmittedAddresses(CoinInstance coinInstance, ArrayList requestedParams, + Set returned) { + Set requested = new HashSet<>(); + try { + if (requestedParams.size() > 1) { + JSONArray arr = new JSONArray(requestedParams.get(1)); + for (int i = 0; i < arr.length(); i++) { + requested.add(arr.getString(i)); + } + } + } catch (Exception e) { + LOGGER.warning("[httpclient] evictOmittedAddresses unparseable request params - " + e.getMessage()); + return; + } + for (String addr : requested) { + if (returned.contains(addr)) { continue; } - - double fee = fees.get(ticker).getAsDouble(); - - coinInstance.addRelayFee(coinTicker, fee); - - if (logCount % 30 == 0) - LOGGER.log(Level.INFO, "[httpclient] Got relayfee for currency " + ticker + " - " + fee); - else - LOGGER.log(Level.FINER, "[httpclient] Got relayfee for currency " + ticker + " - " + fee); + AddressBalance balance = coinInstance.getAddressBalance(addr); + if (balance == null) { + continue; + } + balance.clearUtxos(); + // clearUtxos drops rows but leaves the cached balance behind; + // without this the address keeps reporting its old getbalance + // after eviction (the normal path always re-adds rows, whose + // addUtxo recalculates — the omit path is the first clear with + // no following add). + balance.calculateBalance(); } - logCount += 1; + // Routine success is silent by operator order: this runs on every + // GUI poll per coin, and an INFO here is pure log spam. Real + // failures (unparseable params above) still warn. } public JsonObject getRawTransaction(CoinTicker coinTicker, String txid, boolean verbose) { @@ -626,13 +705,11 @@ public JsonObject getRawTransaction(CoinTicker coinTicker, String txid, boolean JsonObject params = new JsonObject(); params.addProperty("method", "getrawtransaction"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getRawTransaction " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getRawTransaction " + res); - if (res == null) return null; - - return new Gson().fromJson(res, JsonObject.class); + return parseEnvelope(res, "getRawTransaction"); } public JsonObject getRawMempool(CoinTicker coinTicker, boolean verbose) { @@ -645,13 +722,11 @@ public JsonObject getRawMempool(CoinTicker coinTicker, boolean verbose) { JsonObject params = new JsonObject(); params.addProperty("method", "getrawmempool"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getRawMempool " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getRawMempool " + res); - - if (res == null) return null; - return new Gson().fromJson(res, JsonObject.class); + return parseEnvelope(res, "getRawMempool"); } public void getBlockCount(CoinTicker coinTicker) { @@ -665,42 +740,96 @@ public void getBlockCount(CoinTicker coinTicker) { params.addProperty("method", "getblockcount"); params.add("params", innerParams); - String res = doPost("/", params); + String res = executeRequest("/", params); - if (res == null) return; + JsonObject result = parseEnvelope(res, "getBlockCount " + coinTicker); + if (result == null) return; - JsonObject result = new Gson().fromJson(res, JsonObject.class); int blockCount = result.get("result").getAsInt(); coinInstance.addBlockCount(coinTicker, blockCount); - LOGGER.log(Level.FINER, "[httpclient] Got blockcount for currency " + coinTicker + " - " + blockCount); + LOGGER.finer("[httpclient] Got blockcount for currency " + coinTicker + " - " + blockCount); } public void getAllBlockCounts() { - String res = doGet("/height"); + String res = executeRequest("/height", null); - if (res == null) return; + if (res == null) { + // Total backend outage: every coin misses, not none. The old + // early return froze all heights with isInstanceRunning stuck + // true and zero log output — the exact blind spot behind a + // multi-minute stale tip with a clean log. + for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { + noteHeightMiss(coinInstance, "null /height response"); + } + return; + } - JsonObject result = new Gson().fromJson(res, JsonObject.class); - JsonObject blockCounts = result.get("result").getAsJsonObject(); + final JsonObject blockCounts; + try { + JsonObject result = new Gson().fromJson(res, JsonObject.class); + blockCounts = result.get("result").getAsJsonObject(); + } catch (RuntimeException e) { + // Malformed payload (bad JSON, missing/non-object result): + // same accounting as a null response — every coin misses + // rather than aborting the update where the generic caller + // catch buries it outside streak accounting. + for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { + noteHeightMiss(coinInstance, "unparseable /height response"); + } + return; + } - for (CoinTicker coinTicker : CoinTicker.coins()) { - CoinInstance coinInstance = CoinInstance.getInstance(coinTicker); - String ticker = CoinTickerUtils.tickerToString(coinTicker); + for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { + String ticker = CoinTickerUtils.tickerToString(coinInstance.getTicker()); if (!blockCounts.keySet().contains(ticker) || blockCounts.get(ticker).isJsonNull()) { - coinInstance.incrementUpdateFailures(); + noteHeightMiss(coinInstance, "no " + ticker + " in /height response"); + continue; + } + + final int blockCount; + try { + blockCount = blockCounts.get(ticker).getAsInt(); + } catch (RuntimeException e) { + // Wrong-typed value (string/object/array where a height + // belongs): same accounting as a missing entry — this coin + // misses, the loop continues for the rest. + noteHeightMiss(coinInstance, "bad " + ticker + " in /height response"); continue; } - int blockCount = blockCounts.get(ticker).getAsInt(); + coinInstance.addBlockCount(coinInstance.getTicker(), blockCount); + int recovered = coinInstance.resetUpdateFailures(); + if (recovered > 0) { + LOGGER.warning("[httpclient] " + ticker + " height updates recovered after " + + recovered + " consecutive failures; fresh height " + blockCount); + } - coinInstance.addBlockCount(coinTicker, blockCount); - coinInstance.resetUpdateFailures(); + LOGGER.finer("[httpclient] Got blockcount for currency " + ticker + " - " + blockCount); + } + } - LOGGER.log(Level.FINER, "[httpclient] Got blockcount for currency " + ticker + " - " + blockCount); + /** + * Records one missed height update, LOUD exactly twice per episode: + * first miss and recovery. Everything between is silent by operator + * order — a persistent backend gap re-warning every minute is spam, + * not signal. The -1112s in consumer logs are the visible symptom + * while a coin is unavailable. + */ + private static void noteHeightMiss(CoinInstance coinInstance, String reason) { + String ticker = CoinTickerUtils.tickerToString(coinInstance.getTicker()); + int streak = coinInstance.incrementUpdateFailures(); + if (streak != 1) { + return; } + long ageSec = coinInstance.getLastBlockCountMillis() <= 0 ? -1 + : (System.currentTimeMillis() - coinInstance.getLastBlockCountMillis()) / 1000; + String lastGood = ageSec < 0 ? "no height ever received" + : "last-good height age " + ageSec + "s"; + LOGGER.warning("[httpclient] " + ticker + " height update failed (" + reason + + "); streak 1, serving stale height, " + lastGood); } public JsonObject getBlock(CoinTicker coinTicker, String hash, boolean verbose) { @@ -714,13 +843,11 @@ public JsonObject getBlock(CoinTicker coinTicker, String hash, boolean verbose) JsonObject params = new JsonObject(); params.addProperty("method", "getblock"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getBlock " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getBlock " + res); - - if (res == null) return null; - return new Gson().fromJson(res, JsonObject.class); + return parseEnvelope(res, "getBlock"); } public JsonObject getBlockHash(CoinTicker coinTicker, int height) { @@ -731,13 +858,11 @@ public JsonObject getBlockHash(CoinTicker coinTicker, int height) { JsonObject params = new JsonObject(); params.addProperty("method", "getblockhash"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getBlockHash " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getBlockHash " + res); - if (res == null) return null; - - return new Gson().fromJson(res, JsonObject.class); + return parseEnvelope(res, "getBlockHash"); } public JsonObject getTransaction(CoinTicker coinTicker, String txid, boolean verbose) { @@ -751,13 +876,11 @@ public JsonObject getTransaction(CoinTicker coinTicker, String txid, boolean ver JsonObject params = new JsonObject(); params.addProperty("method", "gettransaction"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getTransaction " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getTransaction " + res); - - if (res == null) return null; - return new Gson().fromJson(res, JsonObject.class); + return parseEnvelope(res, "getTransaction"); } public JsonObject sendRawTransaction(CoinTicker coinTicker, String rawTx) { @@ -770,13 +893,20 @@ public JsonObject sendRawTransaction(CoinTicker coinTicker, String rawTx) { JsonObject params = new JsonObject(); params.addProperty("method", "sendrawtransaction"); params.add("params", innerParams); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] sendRawTransaction " + res); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] sendRawTransaction " + res); - - if (res == null) return null; - - return new Gson().fromJson(res, JsonObject.class); + // Full envelope passthrough (including upstream error member) — the + // handler extracts structured {code,message} details for the GUI. + if (res == null) { + return null; + } + try { + return JsonParser.parseString(res).getAsJsonObject(); + } catch (JsonSyntaxException | IllegalStateException e) { + LOGGER.warning("[httpclient] sendRawTransaction invalid upstream JSON - " + e.getMessage()); + return null; + } } /** @@ -796,7 +926,7 @@ public JsonArray getHistory(CoinTicker coinTicker, int startTime, int endTime, i ArrayList utxoParams = coinInstance.getUTXOParams(); if (utxoParams.size() == 0) { - LOGGER.log(Level.WARNING, "[httpclient] getHistory " + coinInstance.getTicker() + " null param size"); + LOGGER.warning("[httpclient] getHistory " + coinInstance.getTicker() + " null param size"); return null; } @@ -806,23 +936,29 @@ public JsonArray getHistory(CoinTicker coinTicker, int startTime, int endTime, i params.addProperty("method", "gethistory"); params.add("params", innerParams); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getHistory " + coinInstance.getTicker() + " " + res); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getHistory " + coinInstance.getTicker() + " " + res); if (res == null) { - LOGGER.log(Level.WARNING, "[httpclient] getHistory " + coinInstance.getTicker() + " null post result"); return null; } - JsonArray json = new Gson().fromJson(res, JsonArray.class); + JsonArray json; + try { + json = new Gson().fromJson(res, JsonArray.class); + } catch (Exception e) { + LOGGER.warning("[httpclient] getHistory parsing error - Response: " + res + " - " + e.getMessage()); + return null; + } + if (json == null) { - LOGGER.log(Level.WARNING, "[httpclient] getHistory " + coinInstance.getTicker() + " null json"); + LOGGER.warning("[httpclient] getHistory " + coinInstance.getTicker() + " null json"); return null; } List historyList = new ArrayList<>(); for (JsonElement elements : json) { for (JsonElement element : elements.getAsJsonArray()) { - //LOGGER.log(Level.WARNING, "*** DEBUG *** [httpclient] getHistory " + element); + //LOGGER.warning("*** DEBUG *** [httpclient] getHistory " + element); JsonObject jsonObject = element.getAsJsonObject(); List fromAddresses = new Gson().fromJson(jsonObject.get("from_addresses"), new TypeToken>() { @@ -848,7 +984,7 @@ public JsonArray getHistory(CoinTicker coinTicker, int startTime, int endTime, i // Return the latest transaction history JsonArray txs = coinInstance.getAllTransactions(); if (txs == null) { - LOGGER.log(Level.WARNING, "[httpclient] getHistory " + coinInstance.getTicker() + " null txs"); + LOGGER.warning("[httpclient] getHistory " + coinInstance.getTicker() + " null txs"); return null; } @@ -876,7 +1012,7 @@ public JsonArray getTransactionHistory(CoinTicker coinTicker, int startTime, int ArrayList utxoParams = coinInstance.getUTXOParams(); if (utxoParams.size() == 0) { - LOGGER.log(Level.WARNING, "[httpclient] getAddressHistory " + coinInstance.getTicker() + " null param size"); + LOGGER.warning("[httpclient] getAddressHistory " + coinInstance.getTicker() + " null param size"); return null; } @@ -886,23 +1022,28 @@ public JsonArray getTransactionHistory(CoinTicker coinTicker, int startTime, int params.addProperty("method", "getaddresshistory"); params.add("params", innerParams); - String res = doPost("/", params); - LOGGER.log(Level.FINER, "[httpclient] getAddressHistory " + coinInstance.getTicker() + " " + res); + String res = executeRequest("/", params); + LOGGER.finer("[httpclient] getAddressHistory " + coinInstance.getTicker() + " " + res); if (res == null) { - LOGGER.log(Level.WARNING, "[httpclient] getAddressHistory " + coinInstance.getTicker() + " null post result"); return null; } - JsonArray json = new Gson().fromJson(res, JsonArray.class); + JsonArray json; + try { + json = new Gson().fromJson(res, JsonArray.class); + } catch (Exception e) { + LOGGER.warning("[httpclient] getAddressHistory parsing error - Response: " + res + " - " + e.getMessage()); + return null; + } if (json == null) { - LOGGER.log(Level.WARNING, "[httpclient] getAddressHistory " + coinInstance.getTicker() + " null json"); + LOGGER.warning("[httpclient] getAddressHistory " + coinInstance.getTicker() + " null json"); return null; } List historyList = new ArrayList<>(); for (JsonElement elements : json) { for (JsonElement element : elements.getAsJsonArray()) { - //LOGGER.log(Level.WARNING, "*** DEBUG *** [httpclient] getAddressHistory " + element ); + //LOGGER.warning("*** DEBUG *** [httpclient] getAddressHistory " + element ); JsonObject jsonObject = element.getAsJsonObject(); String txid = jsonObject.get("tx_hash").getAsString(); @@ -924,7 +1065,7 @@ public JsonArray getTransactionHistory(CoinTicker coinTicker, int startTime, int } else ++fails; } catch (Exception e) { - e.printStackTrace(); + LOGGER.warning("[httpclient] getRawTransaction failed - " + e.getMessage()); ++fails; } } @@ -954,7 +1095,7 @@ public JsonArray getTransactionHistory(CoinTicker coinTicker, int startTime, int } else ++fails; } catch (Exception e) { - e.printStackTrace(); + LOGGER.warning("[httpclient] getRawTransaction(vout) failed - " + e.getMessage()); ++fails; } } @@ -1039,7 +1180,7 @@ public JsonArray getTransactionHistory(CoinTicker coinTicker, int startTime, int // Return the latest transaction history JsonArray txs = coinInstance.getAllTransactions(); if (txs == null) { - LOGGER.log(Level.WARNING, "[httpclient] getAddressHistory " + coinInstance.getTicker() + " null txs"); + LOGGER.warning("[httpclient] getAddressHistory " + coinInstance.getTicker() + " null txs"); return null; } @@ -1050,10 +1191,6 @@ public JsonArray getTransactionHistory(CoinTicker coinTicker, int startTime, int return filterHistory(txs, startTime, endTime); } - private boolean validateResponse(HttpResponse response) { - return response.getStatusLine().getStatusCode() == 200 && response.getEntity().getContentLength() != 0; - } - /** * Filters the transaction array in place. This does not make a copy but modifies * the existing list. diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/client/HttpClientConfig.java b/src/main/java/io/xlite/daemon/app/net/api/http/client/HttpClientConfig.java new file mode 100644 index 0000000..64411c9 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/client/HttpClientConfig.java @@ -0,0 +1,60 @@ +package io.xlite.daemon.app.net.api.http.client; + +/** + * Centralized configuration management for HTTP client settings. + * This class consolidates all timeout and configuration constants + * to eliminate duplication across HTTPClient, EXRWrapper, and other classes. + */ +public final class HttpClientConfig { + + // HTTP timeout configurations (in milliseconds) + public static final int HTTP_TIMEOUT_MS = 30000; + + // Server management configurations + public static final int HEALTH_CHECK_INTERVAL_MS = 5000; + public static final int CAPABILITY_PROBE_TIMEOUT_MS = 30000; + + // Retry configurations + public static final int MAX_RETRY_ATTEMPTS = 3; + + // Logging configuration + public static final String LOG_TAG = "[httpclient]"; + + // Capability probing configurations + public static final int CAPABILITY_PROBE_WAIT_TIMEOUT_MS = 10000; + public static final int CAPABILITY_PROBE_WAIT_INTERVAL_MS = 100; + + // Logging configurations + public static final int LOG_COUNT_MODULO = 30; + + // Static initializer to validate configuration consistency + static { + // Validate that HTTP timeout is reasonable + if (HTTP_TIMEOUT_MS < 1000) { + throw new IllegalStateException("HTTP timeout too low: " + HTTP_TIMEOUT_MS + "ms"); + } + + // Validate that health check interval is reasonable + if (HEALTH_CHECK_INTERVAL_MS < 100) { + throw new IllegalStateException("Health check interval too low: " + HEALTH_CHECK_INTERVAL_MS + "ms"); + } + + // Validate that capability probe timeout is reasonable + if (CAPABILITY_PROBE_TIMEOUT_MS < 1000) { + throw new IllegalStateException("Capability probe timeout too low: " + CAPABILITY_PROBE_TIMEOUT_MS + "ms"); + } + + // Validate that max retry attempts is reasonable + if (MAX_RETRY_ATTEMPTS < 0) { + throw new IllegalStateException("Max retry attempts cannot be negative: " + MAX_RETRY_ATTEMPTS); + } + } + + /** + * Private constructor to prevent instantiation. + * This class only contains static constants and should not be instantiated. + */ + private HttpClientConfig() { + throw new UnsupportedOperationException("HttpClientConfig is a utility class and cannot be instantiated"); + } +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/client/HttpUtils.java b/src/main/java/io/xlite/daemon/app/net/api/http/client/HttpUtils.java new file mode 100644 index 0000000..9a736de --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/client/HttpUtils.java @@ -0,0 +1,86 @@ +package io.xlite.daemon.app.net.api.http.client; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +/** + * Central HTTP operations utility class. + * This class extracts common HTTP patterns from HTTPClient and EXRWrapper + * to eliminate code duplication and provide a unified HTTP execution interface. + */ +public class HttpUtils { + + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + + /** + * Execute HTTP request with common boilerplate pattern. + * This method provides unified HTTP execution with proper resource cleanup, + * replacing the duplicate executeHttpRequest methods in HTTPClient and EXRWrapper. + * + * @param client The HTTP client to use for the request + * @param request The HTTP request to execute (HttpGet or HttpPost) + * @param operation Description of the operation for logging purposes + * @return Response string or null on error + */ + public static String executeHttpRequest(CloseableHttpClient client, + HttpRequestBase request, String operation) { + CloseableHttpResponse response = null; + try { + response = client.execute(request); + if (validateResponse(response)) { + HttpEntity entity = response.getEntity(); + String result = EntityUtils.toString(entity); + EntityUtils.consume(entity); + return result; + } else { + LOGGER.warning(HttpClientConfig.LOG_TAG + " " + operation + + " failed with status " + response.getStatusLine().getStatusCode()); + return null; + } + } catch (IOException e) { + LOGGER.warning(HttpClientConfig.LOG_TAG + " " + operation + " failed: " + e.getMessage()); + return null; + } finally { + request.reset(); + if (response != null) { + try { + response.close(); + } catch (IOException e) { + LOGGER.warning(HttpClientConfig.LOG_TAG + " Failed to close response: " + e.getMessage()); + } + } + } + } + + /** + * Centralized response validation. + * This method provides consistent response validation across all HTTP operations, + * replacing the duplicate validateResponse methods in HTTPClient and EXRWrapper. + * + * @param response The HTTP response to validate + * @return true if response is valid, false otherwise + */ + public static boolean validateResponse(HttpResponse response) { + return response.getStatusLine().getStatusCode() == 200 && + response.getEntity() != null && + response.getEntity().getContentLength() != 0; + } + + /** + * Private constructor to prevent instantiation. + * This class only contains static utility methods and should not be instantiated. + */ + private HttpUtils() { + throw new UnsupportedOperationException("HttpUtils is a utility class and cannot be instantiated"); + } +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/master/HTTPServerHandler.java b/src/main/java/io/xlite/daemon/app/net/api/http/master/HTTPServerHandler.java new file mode 100644 index 0000000..a96867f --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/master/HTTPServerHandler.java @@ -0,0 +1,422 @@ +package io.xlite.daemon.app.net.api.http.master; + +import com.google.common.base.Preconditions; +import com.google.gson.JsonArray; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.subgraph.orchid.encoders.Base64; +import io.xlite.daemon.app.Version; +import io.xlite.daemon.app.coinconfig.CoinConfig; +import io.xlite.daemon.app.coinconfig.CoinConfigRegistry; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.util.ConfigHelper; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.*; +import io.netty.util.CharsetUtil; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Locale; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class HTTPServerHandler extends SimpleChannelInboundHandler { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private ConfigHelper configHelper; + + public HTTPServerHandler() { + configHelper = new ConfigHelper("master"); + + if (configHelper.getRpcUsername().isEmpty() && configHelper.getRpcPassword().isEmpty()) { + configHelper.setRpcUsername(generateRandomString(12)); + configHelper.setRpcPassword(generateRandomString(32)); + + configHelper.writeConfig(); + } + } + + private String generateRandomString(int length) { + SecureRandom secureRandom = new SecureRandom(); + + byte[] token = new byte[length]; + secureRandom.nextBytes(token); + + return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(token); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + super.channelInactive(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + LOGGER.warning("[http-master] Exception caught on channel: " + cause.getMessage()); + + FullHttpResponse httpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST); + writeResponse(ctx, httpResponse, null); + ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + ctx.close(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { + LOGGER.finer("[http-server-handler] DEBUG: Channel read complete. Flushing context."); + super.channelReadComplete(ctx); + ctx.flush(); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) { + boolean successfulAuth = false; + HttpResponseStatus status = HttpResponseStatus.OK; + JsonObject response = new JsonObject(); + + if (request != null) { + HttpHeaders httpHeaders = request.headers(); + + if (HttpUtil.is100ContinueExpected(request)) { + send100Continue(ctx); + } + + String headerUser; + String headerPass; + if (httpHeaders.contains("Authorization")) { + String authorization = httpHeaders.get("Authorization"); + + if (authorization != null && authorization.toLowerCase().startsWith("basic")) { + String base64Credentials = authorization.substring("Basic".length()).trim(); + byte[] credDecoded = Base64.decode(base64Credentials); + String credentials = new String(credDecoded, StandardCharsets.UTF_8); + final String[] values = credentials.split(":", 2); + if (values.length < 2) { + LOGGER.warning("[http-server-handler] Malformed Basic Auth header"); + } else { + headerUser = values[0]; + headerPass = values[1]; + + if (headerUser.equals(configHelper.getRpcUsername()) && headerPass.equals(configHelper.getRpcPassword())) { + successfulAuth = true; + LOGGER.finer("[http-server-handler] Successful Auth"); + } + } + } + } + + if (!request.uri().equals("/")) { + JsonObject onlyServerRootJSON = new JsonObject(); + onlyServerRootJSON.addProperty("code", -1002); + onlyServerRootJSON.addProperty("message", "Only the server root ('/') is being served."); + + response.add("error", onlyServerRootJSON); + response.add("result", JsonNull.INSTANCE); + status = HttpResponseStatus.BAD_REQUEST; + } + + if (request.method() != HttpMethod.POST) { + JsonObject onlyPostAllowedJSON = new JsonObject(); + + onlyPostAllowedJSON.addProperty("code", -1003); + onlyPostAllowedJSON.addProperty("message", "Only HTTP POST is accepted."); + + response.add("error", onlyPostAllowedJSON); + response.add("result", JsonNull.INSTANCE); + status = HttpResponseStatus.BAD_REQUEST; + } + + if (!successfulAuth) { + JsonObject onlyServerRootJSON = new JsonObject(); + onlyServerRootJSON.addProperty("code", -1111); + onlyServerRootJSON.addProperty("message", "Unauthorized!"); + + response.add("error", onlyServerRootJSON); + response.add("result", JsonNull.INSTANCE); + status = HttpResponseStatus.FORBIDDEN; + } + } + + if (status != HttpResponseStatus.OK) { + ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); + FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); + + writeResponse(ctx, httpResponse, request); + ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + return; + } + + if (request != null) { + String content = request.content().toString(CharsetUtil.UTF_8); + JsonObject jsonReq = null; + + try { + jsonReq = JsonParser.parseString(content).getAsJsonObject(); + + Preconditions.checkNotNull(jsonReq); + + if (!jsonReq.has("method") || !jsonReq.has("params")) { + ctx.close(); + throw new IllegalArgumentException("Bad JSON-RPC request by client."); + } + } catch (Exception e) { + LOGGER.info("Failed Content: " + content); + LOGGER.warning("[http-master] Failed to parse JSON-RPC request" + e.getMessage()); + JsonObject errorParsingJSON = new JsonObject(); + errorParsingJSON.addProperty("code", -1001); + errorParsingJSON.addProperty("message", "Error parsing JSON."); + + response.add("error", errorParsingJSON); + response.add("result", JsonNull.INSTANCE); + if (e instanceof IllegalArgumentException) { + LOGGER.finer("[http-server-handler] WARNING: Client sent valid JSON, but did not specify method and/or parameters!"); + } else { + LOGGER.finer("[http-server-handler] WARNING: Client sent invalid JSON!"); + } + status = HttpResponseStatus.BAD_REQUEST; + } + + if (status == HttpResponseStatus.OK) { + Preconditions.checkNotNull(jsonReq); + + String method = jsonReq.get("method").getAsString(); + JsonArray params = jsonReq.get("params").getAsJsonArray(); + + // Master surface handles wallet management — params may contain + // passwords; never log them verbatim. Ping is high-frequency liveness. + String methodLower = method == null ? null : method.toLowerCase(Locale.ROOT); + if ("ping".equals(methodLower)) { + LOGGER.finer("[http-server-handler] RPC CALL: " + method + " PARAMS: "); + } else { + LOGGER.info("[http-server-handler] RPC CALL: " + method + " PARAMS: "); + } + + response = getResponse(method, params); + LOGGER.finer(response.toString()); + } else { + ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); + FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); + + writeResponse(ctx, httpResponse, request); + ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + return; + } + + if (request instanceof LastHttpContent) { + ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); + + FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); + + if (!writeResponse(ctx, httpResponse, request)) { + ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + } + } + } + } + + public JsonObject getResponse(String method, JsonArray params) { + JsonObject response = new JsonObject(); + boolean shutdownRequested = false; + + String normalizedMethod = method == null ? "" : method.toLowerCase(Locale.ROOT); + switch (normalizedMethod) { + case "reloadconfig": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: reloadconfig \n\ntoken (string, required)"); + + response.add("error", errorJSON); + break; + } + + CoinTicker ticker = CoinTickerUtils.stringToTicker(params.get(0).getAsString()); + if (ticker == null) { + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Unknown ticker: " + params.get(0).getAsString()); + response.add("error", errorJSON); + break; + } + + CoinInstance instance = CoinInstance.getInstance(ticker); + if (instance == null) { + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Coin instance not found for ticker: " + CoinTickerUtils.tickerToString(ticker)); + response.add("error", errorJSON); + break; + } + + Thread t = new Thread(() -> { + try { + Thread.sleep(500); + instance.reloadConfig(); + } catch (InterruptedException e) { + LOGGER.warning("[http-master] Interrupted during reloadconfig for " + ticker + ", " + e.getMessage()); + } + }); + t.setDaemon(true); + t.start(); + + response.addProperty("result", true); + response.add("error", JsonNull.INSTANCE); + break; + } + case "version": { + response.addProperty("result", Version.CLIENT_VERSION); + response.add("error", JsonNull.INSTANCE); + break; + } +// case "reloadconfigs": { +// boolean success = true; +// for (CoinInstance instance : CoinInstance.getCoinInstances()) { +// if (!CoinTickerUtils.isActiveTicker(instance.getTicker())) +// continue; +// +// try { +// LOGGER.info(instance.getTicker().toString()); +// instance.reloadConfig(); +// } catch (Exception e) { +// success = false; +// e.printStackTrace(); +// } +// } +// +// response.addProperty("result", success); +// response.add("error", JsonNull.INSTANCE); +// break; +// } + case "ping": { + response.addProperty("result", 1); + response.add("error", JsonNull.INSTANCE); + break; + } + case "help": { + String helpString = "Master JSON-RPC server\n" + + "This JSON-RPC server is served by " + CoinInstance.getVersionString() + "\n" + + "\n" + + "help - Display the help\n" + + "\n=====RPC Master=====\n" + + "ping - Lightweight liveness probe (result 1)\n" + + "stop - Shutdown the server\n" + + "reloadconfig - Reload configuration for specified token\n" + + "getCoins - List coin configurations (alias listCoins)\n" + + "version - Get version\n"; +// + "reloadconfigs - Reload all configuration files\n"; + + response.addProperty("result", helpString); + response.add("error", JsonNull.INSTANCE); + break; + } + case "stop": { + shutdownRequested = true; + response.addProperty("result", "shutting down..."); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getcoins": + case "listcoins": { + if (params == null || params.size() != 0) { + JsonObject err = new JsonObject(); + err.addProperty("code", -1); + err.addProperty("message", "Usage: getCoins"); + response.add("error", err); + response.add("result", JsonNull.INSTANCE); + break; + } + // Atomic snapshot — avoids isLoaded()/list() TOCTOU; empty check is the gate. + java.util.Map snap = CoinConfigRegistry.list(); + if (snap.isEmpty()) { + JsonObject err = new JsonObject(); + err.addProperty("code", -1); + err.addProperty("message", "Coin configs not loaded"); + response.add("error", err); + response.add("result", JsonNull.INSTANCE); + break; + } + // Single DTO source — delegate to CoinConfig.toDtoMap() (authenticated via channelRead0) + try { + JsonArray arr = new JsonArray(); + snap.values().stream() + .sorted(java.util.Comparator.comparing(CoinConfig::getTicker)) + .forEach(cfg -> { + java.util.Map dto = cfg.toDtoMap(); + JsonObject o = new JsonObject(); + o.addProperty("ticker", (String) dto.get("ticker")); + o.addProperty("blockchain", (String) dto.get("blockchain")); + o.addProperty("verId", (String) dto.get("verId")); + o.addProperty("addressPrefix", ((Number) dto.get("addressPrefix")).intValue()); + o.addProperty("scriptPrefix", ((Number) dto.get("scriptPrefix")).intValue()); + o.addProperty("secretPrefix", ((Number) dto.get("secretPrefix")).intValue()); + o.addProperty("coin", ((Number) dto.get("coin")).longValue()); + o.addProperty("feePerByte", ((Number) dto.get("feePerByte")).longValue()); + o.addProperty("minTxFee", ((Number) dto.get("minTxFee")).longValue()); + o.addProperty("port", ((Number) dto.get("port")).intValue()); + Object dust = dto.get("dustAmount"); + if (dust == null) + o.add("dustAmount", JsonNull.INSTANCE); + else + o.addProperty("dustAmount", ((Number) dust).longValue()); + arr.add(o); + }); + response.add("result", arr); + response.add("error", JsonNull.INSTANCE); + } catch (IllegalStateException e) { + LOGGER.warning("[http-master] getCoins DTO error: " + e.getMessage()); + JsonObject err = new JsonObject(); + err.addProperty("code", -1); + err.addProperty("message", e.getMessage()); + response.add("error", err); + response.add("result", JsonNull.INSTANCE); + } + break; + } + default: { + JsonObject methodNotFound = new JsonObject(); + methodNotFound.addProperty("code", -32601); + methodNotFound.addProperty("message", "Method not found."); + response.add("error", methodNotFound); + response.add("result", JsonNull.INSTANCE); + break; + } + } + + if (shutdownRequested) { + Thread t = new Thread(() -> System.exit(0)); + t.setDaemon(true); + t.start(); + } + return response; + } + + private boolean writeResponse(ChannelHandlerContext ctx, FullHttpResponse httpResponse, FullHttpRequest request) { + boolean keepAlive = false; + if (request != null) + keepAlive = HttpUtil.isKeepAlive(request); + + httpResponse.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json"); + httpResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, httpResponse.content().readableBytes()); + httpResponse.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); + httpResponse.headers().set(HttpHeaderNames.SERVER, CoinInstance.getVersionString()); + + LOGGER.finer("[http-server-handler] Writing response to channel. Keep alive? " + keepAlive); + LOGGER.finer("[http-server-handler] Response content: " + httpResponse.content().toString(CharsetUtil.UTF_8)); + ctx.write(httpResponse); + + return keepAlive; + } + + private static void send100Continue(ChannelHandlerContext ctx) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE); + ctx.write(response); + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/master/HTTPServerInitializer.java b/src/main/java/io/xlite/daemon/app/net/api/http/master/HTTPServerInitializer.java new file mode 100644 index 0000000..3caadc3 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/master/HTTPServerInitializer.java @@ -0,0 +1,30 @@ +package io.xlite.daemon.app.net.api.http.master; + +import io.xlite.daemon.app.net.api.http.server.ExceptionHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpRequestDecoder; +import io.netty.handler.codec.http.HttpResponseEncoder; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; + +public class HTTPServerInitializer extends ChannelInitializer { + public HTTPServerInitializer() { + } + + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline pipeline = ch.pipeline(); + + pipeline.addLast(new WriteTimeoutHandler(30)); + pipeline.addLast(new ReadTimeoutHandler(30)); + pipeline.addLast(new HttpRequestDecoder()); + pipeline.addLast(new HttpResponseEncoder()); + pipeline.addLast(new HttpObjectAggregator(100000000)); + pipeline.addLast(new HTTPServerHandler()); + pipeline.addLast(new ExceptionHandler()); + } + +} diff --git a/src/main/java/io/cloudchains/app/net/api/http/server/ExceptionHandler.java b/src/main/java/io/xlite/daemon/app/net/api/http/server/ExceptionHandler.java similarity index 92% rename from src/main/java/io/cloudchains/app/net/api/http/server/ExceptionHandler.java rename to src/main/java/io/xlite/daemon/app/net/api/http/server/ExceptionHandler.java index 0c4fcbb..d5b1da8 100644 --- a/src/main/java/io/cloudchains/app/net/api/http/server/ExceptionHandler.java +++ b/src/main/java/io/xlite/daemon/app/net/api/http/server/ExceptionHandler.java @@ -1,8 +1,8 @@ -package io.cloudchains.app.net.api.http.server; +package io.xlite.daemon.app.net.api.http.server; import com.google.gson.JsonNull; import com.google.gson.JsonObject; -import io.cloudchains.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinInstance; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelDuplexHandler; @@ -11,7 +11,6 @@ import io.netty.handler.codec.http.*; import io.netty.util.CharsetUtil; -import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; @@ -21,7 +20,7 @@ public class ExceptionHandler extends ChannelDuplexHandler { @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - LOGGER.log(Level.FINER, cause.getMessage()); + LOGGER.warning("[http-server] Channel exception" + cause.getMessage()); writeErrorResponse(ctx); } diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/server/HTTPServerHandler.java b/src/main/java/io/xlite/daemon/app/net/api/http/server/HTTPServerHandler.java new file mode 100644 index 0000000..a6d2a4a --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/server/HTTPServerHandler.java @@ -0,0 +1,1879 @@ +package io.xlite.daemon.app.net.api.http.server; + +import com.google.common.base.Preconditions; +import com.google.gson.*; +import com.subgraph.orchid.encoders.Base64; +import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.Version; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.net.api.http.client.HTTPClient; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import io.xlite.daemon.app.util.AddressBalance; +import io.xlite.daemon.app.util.ConfigHelper; +import io.xlite.daemon.app.util.Sats; +import io.xlite.daemon.app.util.UTXO; +import io.xlite.daemon.app.util.Utility; +import io.xlite.daemon.app.wallet.WalletHelper; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.*; +import io.netty.util.CharsetUtil; +import org.bitcoinj.core.*; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SignatureException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +// Define a helper class for output entries +class OutputEntry { + public String address; + public double amount; + + public OutputEntry(String address, double amount) { + this.address = address; + this.amount = amount; + } +} + +public class HTTPServerHandler extends SimpleChannelInboundHandler { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + // RPC methods whose params/responses contain key material — never log them. + private static final Set SENSITIVE_METHODS = Set.of( + "importprivkey", "dumpprivkey", "signmessage"); + + private HTTPClient httpClient; + private CoinInstance coin; + private ConfigHelper configHelper; + + HTTPServerHandler(CoinInstance coin) { + this.coin = coin; + this.configHelper = coin.getConfigHelper(); + this.httpClient = new HTTPClient(5); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + super.channelInactive(ctx); + this.httpClient.close(); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + LOGGER.warning("[http-server-handler] Exception caught on channel for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + cause.getMessage()); + + FullHttpResponse httpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST); + writeResponse(ctx, httpResponse, null); + ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + ctx.close(); + this.httpClient.close(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { + LOGGER.finer("[http-server-handler] DEBUG: Channel read complete. Flushing context."); + super.channelReadComplete(ctx); + ctx.flush(); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) { + boolean successfulAuth = false; + HttpResponseStatus status = HttpResponseStatus.OK; + JsonObject response = new JsonObject(); + + if (request != null) { + HttpHeaders httpHeaders = request.headers(); + + if (HttpUtil.is100ContinueExpected(request)) { + send100Continue(ctx); + } + + String headerUser; + String headerPass; + if (httpHeaders.contains("Authorization")) { + String authorization = httpHeaders.get("Authorization"); + + if (authorization != null && authorization.toLowerCase().startsWith("basic")) { + String base64Credentials = authorization.substring("Basic".length()).trim(); + byte[] credDecoded = Base64.decode(base64Credentials); + String credentials = new String(credDecoded, StandardCharsets.UTF_8); + final String[] values = credentials.split(":", 2); + if (values.length < 2) { + LOGGER.warning("[http-server-handler] Malformed Basic Auth header"); + } else { + headerUser = values[0]; + headerPass = values[1]; + + if (MessageDigest.isEqual(headerUser.getBytes(StandardCharsets.UTF_8), + configHelper.getRpcUsername().getBytes(StandardCharsets.UTF_8)) + && MessageDigest.isEqual(headerPass.getBytes(StandardCharsets.UTF_8), + configHelper.getRpcPassword().getBytes(StandardCharsets.UTF_8))) { + successfulAuth = true; + LOGGER.finer("[http-server-handler] Successful Auth"); + } + } + } + } + + if (!request.uri().equals("/")) { + JsonObject onlyServerRootJSON = new JsonObject(); + onlyServerRootJSON.addProperty("code", -1002); + onlyServerRootJSON.addProperty("message", "Only the server root ('/') is being served."); + + response.add("error", onlyServerRootJSON); + response.add("result", JsonNull.INSTANCE); + status = HttpResponseStatus.BAD_REQUEST; + } + + if (request.method() != HttpMethod.POST) { + JsonObject onlyPostAllowedJSON = new JsonObject(); + + onlyPostAllowedJSON.addProperty("code", -1003); + onlyPostAllowedJSON.addProperty("message", "Only HTTP POST is accepted."); + + response.add("error", onlyPostAllowedJSON); + response.add("result", JsonNull.INSTANCE); + status = HttpResponseStatus.BAD_REQUEST; + } + + if (!coin.isInstanceRunning()) { + JsonObject instanceNotRunning = new JsonObject(); + + instanceNotRunning.addProperty("code", -1112); + instanceNotRunning.addProperty("message", "This coin is temporarily unavailable."); + + response.add("error", instanceNotRunning); + response.add("result", JsonNull.INSTANCE); + status = HttpResponseStatus.SERVICE_UNAVAILABLE; + } + + if (!successfulAuth) { + JsonObject onlyServerRootJSON = new JsonObject(); + onlyServerRootJSON.addProperty("code", -1111); + onlyServerRootJSON.addProperty("message", "Unauthorized!"); + + response.add("error", onlyServerRootJSON); + response.add("result", JsonNull.INSTANCE); + status = HttpResponseStatus.FORBIDDEN; + } + } + + if (status != HttpResponseStatus.OK) { + ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); + FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); + + writeResponse(ctx, httpResponse, request); + ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + return; + } + + if (request != null) { + String content = request.content().toString(CharsetUtil.UTF_8); + JsonObject jsonReq = null; + + try { + jsonReq = JsonParser.parseString(content).getAsJsonObject(); + + Preconditions.checkNotNull(jsonReq); + + if (!jsonReq.has("method") || !jsonReq.has("params")) { + ctx.close(); + throw new IllegalArgumentException("Bad JSON-RPC request by client."); + } + } catch (Exception e) { + LOGGER.info("Failed Content: " + content); + LOGGER.warning("[http-server-handler] Failed to parse JSON-RPC request for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + JsonObject errorParsingJSON = new JsonObject(); + errorParsingJSON.addProperty("code", -1001); + errorParsingJSON.addProperty("message", "Error parsing JSON."); + + response.add("error", errorParsingJSON); + response.add("result", JsonNull.INSTANCE); + if (e instanceof IllegalArgumentException) { + LOGGER.warning("[http-server-handler] WARNING: Client sent valid JSON, but did not specify method and/or parameters!"); + } else { + LOGGER.warning("[http-server-handler] WARNING: Client sent invalid JSON!"); + } + status = HttpResponseStatus.BAD_REQUEST; + } + + if (status == HttpResponseStatus.OK) { + Preconditions.checkNotNull(jsonReq); + + String method = jsonReq.get("method").getAsString(); + JsonArray params = jsonReq.get("params").getAsJsonArray(); + + String methodLower = method == null ? null : method.toLowerCase(java.util.Locale.ROOT); + boolean sensitiveMethod = methodLower != null && SENSITIVE_METHODS.contains(methodLower); + boolean pingMethod = "ping".equals(methodLower); + if (pingMethod) { + LOGGER.finer("[http-server-handler] RPC CALL: " + coin.getTicker() + " " + method + + " PARAMS: " + params.toString().replace(",", ", ")); + } else { + LOGGER.info("[http-server-handler] RPC CALL: " + coin.getTicker() + " " + method + + " PARAMS: " + (sensitiveMethod ? "" : params.toString().replace(",", ", "))); + } + + response = getResponse(method, params); + if (sensitiveMethod) + LOGGER.finer("[http-server-handler] response withheld (sensitive method)"); + else + LOGGER.finer(response.toString()); + } else { + ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); + FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); + + writeResponse(ctx, httpResponse, request); + ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + return; + } + + if (request instanceof LastHttpContent) { + ByteBuf responseContent = Unpooled.copiedBuffer(response.toString(), CharsetUtil.UTF_8); + + FullHttpResponse httpResponse = new DefaultFullHttpResponse(request.protocolVersion(), status, responseContent); + + if (!writeResponse(ctx, httpResponse, request)) { + ctx.write(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE); + } + } + } + } + + private JsonObject getResponse(String method, JsonArray params) { + JsonObject response = new JsonObject(); + + String normalizedMethod = method == null ? "" : method.toLowerCase(java.util.Locale.ROOT); + switch (normalizedMethod) { + case "reloadconfig": { + Thread t = new Thread(() -> { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + LOGGER.warning("[http-server-handler] Interrupted during reloadconfig for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + } + + coin.reloadConfig(); + }); + t.setDaemon(true); + t.start(); + + response.addProperty("result", true); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getinfo": { + JsonObject infoJSON = new JsonObject(); + + infoJSON.addProperty("protocolversion", coin.getNetworkParameters().getProtocolVersionNum(NetworkParameters.ProtocolVersion.CURRENT)); + infoJSON.addProperty("ticker", CoinTickerUtils.tickerToString(coin.getTicker())); + infoJSON.addProperty("balance", coin.getAllBalances()); + infoJSON.addProperty("testnet", coin.isTestnet()); + infoJSON.addProperty("difficulty", 0.0); + infoJSON.addProperty("connections", 1); // We make this a static value for now, since we are deprecating P2P + infoJSON.addProperty("blocks", CoinInstance.getBlockCountByTicker(coin.getTicker())); + infoJSON.addProperty("keypoolsize", coin.getAddressKeyPairs().size()); + infoJSON.addProperty("keypoololdest", 0.0); + + long relayFeeSats = WalletHelper.getFeePerByte(coin.getNetworkParameters()) * 1000; + BigDecimal relayFeeDecimal = new BigDecimal(relayFeeSats).divide(new BigDecimal(Coin.COIN.value), 8, RoundingMode.DOWN); + infoJSON.addProperty("relayfee", relayFeeDecimal); + + infoJSON.addProperty("networkactive", true); + infoJSON.addProperty("timeoffset", 0); + infoJSON.addProperty("rpcready", coin.isInstanceRunning()); + + response.add("result", infoJSON); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getblockcount": { + response.addProperty("result", CoinInstance.getBlockCountByTicker(coin.getTicker())); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getnetworkinfo": { + JsonObject networkInfoJSON = new JsonObject(); + networkInfoJSON.addProperty("protocolversion", coin.getNetworkParameters().getProtocolVersionNum(NetworkParameters.ProtocolVersion.CURRENT)); + networkInfoJSON.addProperty("ticker", CoinTickerUtils.tickerToString(coin.getTicker())); + networkInfoJSON.addProperty("subversion", CoinInstance.getVersionString()); + networkInfoJSON.addProperty("connections", 1); + networkInfoJSON.addProperty("localservices", "0000000000000000"); + + long relayFeeSats = WalletHelper.getFeePerByte(coin.getNetworkParameters()) * 1000; + BigDecimal relayFeeDecimal = new BigDecimal(relayFeeSats).divide(new BigDecimal(Coin.COIN.value), 8, RoundingMode.DOWN); + + networkInfoJSON.addProperty("relayfee", relayFeeDecimal); + + response.add("result", networkInfoJSON); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getfees": { + JsonObject feesJSON = new JsonObject(); + feesJSON.addProperty("feeperbyte", WalletHelper.getFeePerByte(coin.getNetworkParameters())); + feesJSON.addProperty("mintxfee", WalletHelper.getMinTxFee(coin.getNetworkParameters())); + response.add("result", feesJSON); + response.add("error", JsonNull.INSTANCE); + break; + } + case "listunspent": { + JsonArray unspent = httpClient.getUtxos(coin.getTicker(), 30000); + if (unspent == null) { + response.add("result", JsonNull.INSTANCE); + JsonObject err = new JsonObject(); + err.addProperty("message", "listunspent not ready"); + err.addProperty("code", 3002); + response.add("error", err); + break; + } + response.add("result", unspent); + response.add("error", JsonNull.INSTANCE); + break; + } + case "listtransactions": { + int startTime = 0; + int endTime = 0; + if (params.size() == 2) { + startTime = params.get(0).getAsInt(); + endTime = params.get(1).getAsInt(); + } + JsonArray transactions = httpClient.getHistory(coin.getTicker(), startTime, endTime, 60 * 15); + if (transactions == null) { + response.add("result", JsonNull.INSTANCE); + JsonObject err = new JsonObject(); + err.addProperty("message", "listtransactions not ready"); + err.addProperty("code", 3002); + response.add("error", err); + break; + } + response.add("result", transactions); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getblockchaininfo": { + JsonObject blockchainInfoJSON = new JsonObject(); + + blockchainInfoJSON.addProperty("chain", coin.getNetworkParameters().getPaymentProtocolId()); + blockchainInfoJSON.addProperty("blocks", CoinInstance.getBlockCountByTicker(coin.getTicker())); + blockchainInfoJSON.addProperty("headers", CoinInstance.getBlockCountByTicker(coin.getTicker())); + blockchainInfoJSON.addProperty("verificationprogress", 1.0); + blockchainInfoJSON.addProperty("difficulty", 0.0); + + blockchainInfoJSON.addProperty("initialblockdownload", false); + blockchainInfoJSON.addProperty("pruned", false); + + response.add("result", blockchainInfoJSON); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getblockhash": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: getblockhash index\n\nindex (num, required)"); + + response.add("error", errorJSON); + break; + } + + int blockIndex = params.get(0).getAsInt(); + JsonObject blockHash = httpClient.getBlockHash(coin.getTicker(), blockIndex); + + if (blockHash == null || blockHash.has("error") && !blockHash.get("error").isJsonNull()) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Error obtaining blockhash!"); + response.add("error", errorJSON); + + break; + } + + if (blockHash.has("result")) { + response.add("result", blockHash.get("result")); + response.add("error", JsonNull.INSTANCE); + + break; + } + + response.add("result", blockHash); + response.add("error", JsonNull.INSTANCE); + break; + } + case "sendrawtransaction": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: sendrawtransaction hex-tx\n\nhex-tx (string, required) - Raw transaction, hex encoded"); + + response.add("error", errorJSON); + break; + } + + String rawTx; + Transaction transaction; + try { + rawTx = params.get(0).getAsString(); + transaction = new Transaction(coin.getNetworkParameters(), Hex.decode(rawTx)); + } catch (RuntimeException e) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Error parsing transaction!"); + response.add("error", errorJSON); + + LOGGER.warning("[http-server-handler] Error parsing transaction in sendrawtransaction for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + break; + } + + JsonObject txid = httpClient.sendRawTransaction(coin.getTicker(), rawTx); + if (txid == null || txid.has("error") && !txid.get("error").isJsonNull()) { + int code = -1; + String message = "Error sending transaction!"; + + // Upstream error may be a legacy int (hosted backend) or a + // structured {code,message} object (plugin-adapter) — accept both. + if (txid != null && txid.has("error") && !txid.get("error").isJsonNull()) { + JsonElement errElement = txid.get("error"); + if (errElement.isJsonPrimitive()) { + code = errElement.getAsInt(); + } else if (errElement.isJsonObject()) { + JsonObject errObj = errElement.getAsJsonObject(); + if (errObj.has("code")) + code = errObj.get("code").getAsInt(); + if (errObj.has("message")) + message = errObj.get("message").getAsString(); + } + } + + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", code); + errorJSON.addProperty("message", message); + response.add("error", errorJSON); + + break; + } + + WalletHelper.setAsSpent(coin.getTicker(), transaction, true); + + if (txid.has("result")) { + response.add("result", txid.get("result")); + response.add("error", JsonNull.INSTANCE); + + break; + } + + response.add("result", txid); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getrawtransaction": { + if (params.size() < 1 || params.size() > 2) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: getrawtransaction\n\ntxid (string, required) - TXID is required, verbose(optional)"); + + response.add("error", errorJSON); + break; + } + + String txid = params.get(0).getAsString(); + boolean verbose = false; + + if (params.size() == 2) { + String v = params.get(1).toString(); + if (v.equalsIgnoreCase("true") || v.equals("1")) + verbose = true; + } + + JsonObject rawTransaction = httpClient.getRawTransaction(coin.getTicker(), txid, verbose); + + if (rawTransaction == null || rawTransaction.has("error") && !rawTransaction.get("error").isJsonNull()) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -5); + errorJSON.addProperty("message", "No information available about transaction"); + + response.add("error", errorJSON); + + break; + } + + if (rawTransaction.has("result")) { + response.add("result", rawTransaction.get("result")); + response.add("error", JsonNull.INSTANCE); + + break; + } + + response.add("result", rawTransaction); + response.add("error", JsonNull.INSTANCE); + + break; + } + case "getrawmempool": { + if (params.size() > 2) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: getrawmempool\n\nverbose (int, optional) - VERBOSE is optional"); + + response.add("error", errorJSON); + break; + } + + boolean verbose = false; + + if (params.size() == 2) { + String v = params.get(1).toString(); + if (v.equalsIgnoreCase("true") || v.equals("1")) + verbose = true; + } + + JsonObject rawMempool = httpClient.getRawMempool(coin.getTicker(), verbose); + + if (rawMempool == null || rawMempool.has("error") && !rawMempool.get("error").isJsonNull()) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "No information available"); + + response.add("error", errorJSON); + + break; + } + + if (rawMempool.has("result")) { + response.add("result", rawMempool.get("result")); + response.add("error", JsonNull.INSTANCE); + + break; + } + + response.add("result", rawMempool); + response.add("error", JsonNull.INSTANCE); + + break; + } + case "getblock": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: getblock hash\n\nhash (string, required) - Hash of block to retrieve"); + + response.add("error", errorJSON); + break; + } + + String hash; + try { + hash = params.get(0).getAsString(); + hash = hash.replaceAll("\"", ""); + } catch (JsonParseException e) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Error parsing JSON!"); + response.add("error", errorJSON); + + LOGGER.warning("[http-server-handler] Error parsing JSON in getblock for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + break; + } + + JsonObject block = httpClient.getBlock(coin.getTicker(), hash, true); + + if (block == null || block.has("error") && !block.get("error").isJsonNull()) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Error while retrieving block"); + response.add("error", errorJSON); + + break; + } + + if (block.has("result")) { + response.add("result", block.get("result")); + response.add("error", JsonNull.INSTANCE); + + break; + } + + response.add("result", block); + response.add("error", JsonNull.INSTANCE); + break; + } + case "gettransaction": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: gettransaction txid\n\ntxid (string, required) - The transaction ID"); + + response.add("error", errorJSON); + break; + } + + String txid = params.get(0).getAsString(); + + JsonObject transaction = httpClient.getTransaction(coin.getTicker(), txid, true); + + if (transaction == null || transaction.has("error") && !transaction.get("error").isJsonNull()) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Error while obtaining transaction!"); + response.add("error", errorJSON); + + break; + } + + if (transaction.has("result")) { + response.add("result", transaction.get("result")); + response.add("error", JsonNull.INSTANCE); + + break; + } + + response.add("result", transaction); + response.add("error", JsonNull.INSTANCE); + break; + } + case "getaddressesbyaccount": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: getaddressesbyaccount account\n\naccount (string, required) - The account from which to grab addresses. The only account available (for now) is 'main'."); + + response.add("error", errorJSON); + break; + } + + String account = params.get(0).getAsString(); + + if (account.equals("main")) { + JsonArray addresses = new JsonArray(); + for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { + addresses.add(addressBalance.getAddress().toBase58()); + } + + response.add("result", addresses); + response.add("error", JsonNull.INSTANCE); + } else { + response.add("result", new JsonArray()); + response.add("error", JsonNull.INSTANCE); + } + break; + } + case "createrawtransaction": { + if (params.size() < 2 || params.size() > 3) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", + "Usage: createrawtransaction inputs outputs\n\ninputs (string, required) - Inputs in JSON format\nexample: [{\"txid\": \"id\", \"vout\": n}, ...]\n\noutputs (string, required) - Outputs in JSON format\nexample (legacy): {\"address1\": amount1, \"address2\": amount2, ...}\nexample (new): [{\"address\": \"address1\", \"amount\": amount1}, {\"address\": \"address1\", \"amount\": amount2}, ...]"); + response.add("error", errorJSON); + break; + } + + JsonArray inputs; + // Instead of using a JsonObject for outputs, we’ll parse outputs into a list of + // OutputEntry. + List outputEntries = new ArrayList<>(); + long locktime = 0; + + if (params.size() >= 3) + locktime = params.get(2).getAsLong(); + + try { + inputs = params.get(0).getAsJsonArray(); + for (int i = 0; i < inputs.size(); i++) { + JsonObject input = inputs.get(i).getAsJsonObject(); + if (!input.has("txid") || !input.has("vout")) + throw new JsonParseException("Bad transaction input."); + } + + JsonElement outputsElem = params.get(1); + if (outputsElem.isJsonArray()) { + // New array format: allows duplicate outputs with same address. + JsonArray outputsArray = outputsElem.getAsJsonArray(); + for (JsonElement elem : outputsArray) { + JsonObject obj = elem.getAsJsonObject(); + if (!obj.has("address") || !obj.has("amount")) + throw new JsonParseException("Output entry must have 'address' and 'amount'"); + String addr = obj.get("address").getAsString(); + double amt = obj.get("amount").getAsDouble(); + outputEntries.add(new OutputEntry(addr, amt)); + } + } else if (outputsElem.isJsonObject()) { + // Legacy format: keys are addresses. + JsonObject outputsObject = outputsElem.getAsJsonObject(); + for (String addr : outputsObject.keySet()) { + double amt = outputsObject.get(addr).getAsDouble(); + outputEntries.add(new OutputEntry(addr, amt)); + } + } else { + throw new JsonParseException("Invalid outputs format"); + } + } catch (JsonParseException e) { + LOGGER.warning( + "[http-server-handler] ERROR: Error while parsing JSON for createrawtransaction!"); + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -2); + errorJSON.addProperty("message", "Error parsing JSON"); + response.add("error", errorJSON); + break; + } + + Transaction tx = new Transaction(coin.getNetworkParameters()); + if (locktime > 0 && !tx.isTimeLocked()) { + tx.setLockTime(locktime); + } + + boolean inputSuccess = true; + for (int i = 0; i < inputs.size(); i++) { + JsonObject input = inputs.get(i).getAsJsonObject(); + try { + String txid = input.get("txid").getAsString(); + int vout = input.get("vout").getAsInt(); + tx.addInput(Sha256Hash.wrap(txid), vout, ScriptBuilder.createInputScript(null)); + } catch (Exception e) { + LOGGER.warning( + "[http-server-handler] ERROR: Error while constructing transaction (input phase)!"); + txConstructionError(response, e, "Error while constructing transaction (input phase)"); + inputSuccess = false; + break; + } + } + if (!inputSuccess) + break; + + boolean outputSuccess = true; + + // First, add P2SH outputs. + for (OutputEntry entry : outputEntries) { + try { + LegacyAddress address = LegacyAddress.fromBase58(coin.getNetworkParameters(), entry.address); + Coin outputValue = Coin.valueOf(Sats.fromWholeCoins(entry.amount)); + if (isP2SHAddress(entry.address)) { + LOGGER.fine("[http-server-handler] P2SH Address Found: " + entry.address); + Script p2shScript = ScriptBuilder.createP2SHOutputScript(address.getHash()); + tx.addOutput(outputValue, p2shScript); + } + } catch (Exception e) { + LOGGER.warning( + "[http-server-handler] ERROR: Error while constructing transaction (output phase)!"); + LOGGER.warning("[http-server-handler] Error in createrawtransaction P2SH output phase for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + txConstructionError(response, e, "Error while constructing transaction (output phase)"); + outputSuccess = false; + } + } + // Then, add non-P2SH outputs. + for (OutputEntry entry : outputEntries) { + try { + LegacyAddress address = LegacyAddress.fromBase58(coin.getNetworkParameters(), entry.address); + Coin outputValue = Coin.valueOf(Sats.fromWholeCoins(entry.amount)); + if (!isP2SHAddress(entry.address)) { + tx.addOutput(outputValue, address); + } + } catch (Exception e) { + LOGGER.warning( + "[http-server-handler] ERROR: Error while constructing transaction (output phase)!"); + LOGGER.warning("[http-server-handler] Error in createrawtransaction output phase for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + txConstructionError(response, e, "Error while constructing transaction (output phase)"); + outputSuccess = false; + } + } + + if (!outputSuccess) + break; + + String hexTx = new String(Hex.encode(tx.bitcoinSerialize())); + LOGGER.finer("[http-server-handler] DEBUG: Raw transaction = " + hexTx); + response.addProperty("result", hexTx); + response.add("error", JsonNull.INSTANCE); + break; + } + case "decoderawtransaction": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: decoderawtransaction rawtx\n\nrawtx (string, required) - The raw transaction to decode, hex encoded."); + + response.add("error", errorJSON); + break; + } + + String rawTx = params.get(0).getAsString(); + Transaction tx; + + try { + tx = new Transaction(coin.getNetworkParameters(), Hex.decode(rawTx)); + } catch (Exception e) { + LOGGER.warning("[http-server-handler] Error decoding raw transaction in decoderawtransaction for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + getInvalidTxResponse(response, e); + break; + } + + JsonObject txJSON = new JsonObject(); + + txJSON.addProperty("txid", tx.getHashAsString()); + txJSON.addProperty("version", tx.getVersion()); + txJSON.addProperty("locktime", tx.getLockTime()); + JsonArray vin = new JsonArray(); + + boolean inputParseSuccess = true; + + for (TransactionInput input : tx.getInputs()) { + try { + JsonObject thisVin = new JsonObject(); + thisVin.addProperty("txid", input.getOutpoint().getHash().toString()); + thisVin.addProperty("vout", input.getOutpoint().getIndex()); + + JsonObject scriptSig = new JsonObject(); + scriptSig.addProperty("asm", canonicalizeASM(input.getScriptSig().toString())); + scriptSig.addProperty("hex", new String(Hex.encode(input.getScriptSig().getProgram()))); + + thisVin.add("scriptSig", scriptSig); + thisVin.addProperty("sequence", input.getSequenceNumber()); + + vin.add(thisVin); + } catch (Exception e) { + LOGGER.warning("[http-server-handler] ERROR: Error while parsing transaction inputs!"); + LOGGER.warning("[http-server-handler] Error parsing transaction inputs for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1008); + errorJSON.addProperty("message", "Error while parsing transaction inputs"); + + response.add("error", errorJSON); + + inputParseSuccess = false; + } + } + + if (!inputParseSuccess) + break; + + txJSON.add("vin", vin); + JsonArray vout = new JsonArray(); + boolean outputParseFailed = false; + + for (TransactionOutput output : tx.getOutputs()) { + try { + Script.ScriptType type = output.getScriptPubKey().getScriptType(); + + JsonObject thisVout = new JsonObject(); + + thisVout.addProperty("value", (double) output.getValue().value / 100000000.0); + thisVout.addProperty("n", output.getIndex()); + + JsonObject scriptPubKey = new JsonObject(); + scriptPubKey.addProperty("asm", canonicalizeASM(output.getScriptPubKey().toString())); + scriptPubKey.addProperty("hex", new String(Hex.encode(output.getScriptPubKey().getProgram()))); + + if (type == Script.ScriptType.P2SH) { + scriptPubKey.addProperty("reqSigs", 1); + } else { + scriptPubKey.addProperty("reqSigs", output.getScriptPubKey().getNumberOfSignaturesRequiredToSpend()); + } + + getScriptType(scriptPubKey, type); + + JsonArray addresses = new JsonArray(); + addresses.add(((LegacyAddress) output.getScriptPubKey().getToAddress(coin.getNetworkParameters())).toBase58()); + + scriptPubKey.add("addresses", addresses); + thisVout.add("scriptPubKey", scriptPubKey); + + vout.add(thisVout); + } catch (Exception e) { + outputParseFailed = true; + LOGGER.warning("[http-server-handler] ERROR: Error while parsing transaction outputs!"); + LOGGER.warning("[http-server-handler] Error parsing transaction outputs for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1009); + errorJSON.addProperty("message", "Error while parsing transaction outputs"); + + response.add("error", errorJSON); + } + } + + // A throwing vout must surface as an error, not be silently + // dropped from a success-shaped response. + if (outputParseFailed) + break; + + txJSON.add("vout", vout); + + response.add("result", txJSON); + response.add("error", JsonNull.INSTANCE); + + break; + } + case "signrawtransaction": { + if (params.size() < 1 || params.size() > 3) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: signrawtransaction rawtx\n\nrawtx (string, required) - The raw transaction to sign, hex encoded."); + + response.add("error", errorJSON); + break; + } + + String rawTx = params.get(0).getAsString(); + Transaction tx; +// JsonArray prevtxs; +// JsonArray privkeys; +// +// if (params.size() == 3 && !(params.get(0).isJsonNull() || params.get(2).isJsonNull())) { +// prevtxs = params.get(1).getAsJsonArray(); +// privkeys = params.get(2).getAsJsonArray(); +// } + + try { + tx = new Transaction(coin.getNetworkParameters(), Hex.decode(rawTx)); + } catch (Exception e) { + LOGGER.warning("[http-server-handler] Error decoding raw tx in signrawtransaction for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + getInvalidTxResponse(response, e); + break; + } + + Transaction signedTx = new Transaction(coin.getNetworkParameters()); + + boolean complete = true; + boolean inputFailed = false; + + for (TransactionOutput output : tx.getOutputs()) { + signedTx.addOutput(output); + } + + for (TransactionInput input : tx.getInputs()) { + Sha256Hash txid = input.getOutpoint().getHash(); + long vout = input.getOutpoint().getIndex(); + + ECKey signingKey = getSigningKey(txid, vout); + UTXO utxo = getUtxo(txid, vout); + + if (utxo == null || signingKey == null) { + getInvalidTxResponse(response, new Exception("Transaction contains an utxo/input which does not exist in our wallet.")); + inputFailed = true; + break; + } + + org.bitcoinj.core.UTXO bUtxo = utxo.createUTXO(); + + TransactionOutPoint outPoint = new TransactionOutPoint(coin.getNetworkParameters(), bUtxo.getIndex(), bUtxo.getHash()); + + signedTx.addSignedInput(outPoint, bUtxo.getScript(), signingKey, Transaction.SigHash.ALL, true); +// utxo.setSpent(true); + } + + if (inputFailed) { + // An error response is already set — never hand back a + // partially signed hex claiming complete:true. + LOGGER.warning("[http-server-handler] signrawtransaction aborted with unsigned inputs for " + CoinTickerUtils.tickerToString(coin.getTicker())); + break; + } + + String signedTxHex = new String(Hex.encode(signedTx.bitcoinSerialize())); + JsonObject resultJSON = new JsonObject(); + resultJSON.addProperty("hex", signedTxHex); + resultJSON.addProperty("complete", complete); + + response.add("result", resultJSON); + response.add("error", JsonNull.INSTANCE); + + LOGGER.finer("[DEBUG http-server-handler] Signed Raw Transaction: " + response.toString()); + break; + } + case "gettxout": { + if (params.size() < 2 || params.size() > 3) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: gettxout txid n [include_mempool]\\n" + // + "\\n" + // + "txid (string, required) - The transaction ID\\n" + // + "n (numeric, required) - The vout value\\n" + // + "include_mempool (boolean, optional, default=true) - Whether to include the mempool (WARNING: This can block execution for several seconds)"); + + response.add("error", errorJSON); + break; + } + + String txid = params.get(0).getAsString(); + int n = params.get(1).getAsInt(); + boolean includeMempool = true; + if (params.size() == 3) { + includeMempool = params.get(2).getAsBoolean(); + } + + // attempt to find UTXO in cache + UTXO requested = this.getUtxo(Sha256Hash.wrap(txid), n); + + if (requested != null) { + LOGGER.fine("[http-server-handler] Using cached UTXO for gettxout"); + + org.bitcoinj.core.UTXO utxo = requested.createUTXO(); + JsonObject resultJSON = new JsonObject(); + + resultJSON.addProperty("confirmations", + (CoinInstance.getBlockCountByTicker(coin.getTicker()) - utxo.getHeight()) + 1); + resultJSON.addProperty("value", utxo.getValue().value / 100000000.0); + + JsonObject scriptPubKey = new JsonObject(); + scriptPubKey.addProperty("asm", utxo.getScript().toString()); + scriptPubKey.addProperty("hex", new String(Hex.encode(utxo.getScript().getProgram()))); + scriptPubKey.addProperty("reqSigs", utxo.getScript().getNumberOfSignaturesRequiredToSpend()); + + Script.ScriptType type = utxo.getScript().getScriptType(); + getScriptType(scriptPubKey, type); + + JsonArray addresses = new JsonArray(); + addresses.add(utxo.getAddress()); + scriptPubKey.add("addresses", addresses); + + resultJSON.add("scriptPubKey", scriptPubKey); + resultJSON.addProperty("coinbase", utxo.isCoinbase()); + + response.add("result", resultJSON); + response.add("error", JsonNull.INSTANCE); + break; + } + + if (!includeMempool) { + LOGGER.warning("[http-server-handler] WARNING: Client requested UTXO that is not ours!"); + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + + errorJSON.addProperty("code", -5); + errorJSON.addProperty("message", "Invalid or non-wallet transaction ID (not ours)"); + response.add("error", errorJSON); + break; + } + + // considering mempool, find/wait for the transaction + JsonObject transaction = null; + int retries = includeMempool ? 5 : 1; + for (int i = 0; i < retries; i++) { + transaction = httpClient.getTransaction(coin.getTicker(), txid, true); + if (transaction == null || transaction.has("result") && transaction.get("result").isJsonNull()) { + if (i < retries - 1) { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + continue; + } + } else { + break; + } + } + + if (transaction == null || transaction.has("error") && !transaction.get("error").isJsonNull()) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + + errorJSON.addProperty("code", -5); + errorJSON.addProperty("message", "Invalid transaction ID"); + response.add("error", errorJSON); + break; + } + + // extract the UTXO + try { + // Note: will throw when attempting to parse a coinbase utxo (that's fine) + JsonObject result = transaction.getAsJsonObject("result"); + JsonElement confirmations = result.get("confirmations"); + JsonArray vout = result.getAsJsonArray("vout"); + + if (vout.size() <= n) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + + errorJSON.addProperty("code", -5); + errorJSON.addProperty("message", "Invalid transaction output index"); + response.add("error", errorJSON); + break; + } + + JsonObject entry = vout.get(n).getAsJsonObject(); + JsonElement value = entry.get("value"); + JsonObject scriptPubKey = entry.getAsJsonObject("scriptPubKey"); + JsonElement addr = scriptPubKey.get("address"); + JsonArray addresses = scriptPubKey.getAsJsonArray("addresses"); + + String address = null; + if (addr != null) { + address = addr.getAsString(); + } else { + address = addresses.asList().get(0).getAsString(); + } + + // ensure address belongs to our wallet + boolean isOurs = false; + for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { + String addressCheck = addressBalance.getAddress().toString(); + if (address.equals(addressCheck)) { + isOurs = true; + break; + } + } + + if (!isOurs) { + LOGGER.warning("[http-server-handler] WARNING: Client requested UTXO that cannot be ours!"); + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + + errorJSON.addProperty("code", -5); + errorJSON.addProperty("message", "Invalid or non-wallet transaction ID (cannot be ours)"); + response.add("error", errorJSON); + break; + } + + int count = 0; + if (confirmations != null) { + count = confirmations.getAsInt(); + } + + // ensure UTXO is unspent + // Caution: Beware of race conditions; the backend might return results for 'getTransaction' + // before updating entries for 'getUtxos'. As a result, we only check confirmed transactions. + // Note that unconfirmed UTXOs spent in the memory pool will still be returned, leading to + // a slightly different behavior compared to a core wallet. + if (count > 0) { + // Note: this request is expensive + boolean unspent = false; + JsonArray utxos = httpClient.getUtxosUncached(coin.getTicker(), new String[]{address}); + for (JsonElement utxo : utxos.asList()) { + String newtxid = utxo.getAsJsonObject().get("txid").getAsString(); + int newvout = utxo.getAsJsonObject().get("vout").getAsInt(); + if (newtxid.equals(txid) && newvout == n) { + unspent = true; + break; + } + } + + if (!unspent) { + LOGGER.warning("[http-server-handler] WARNING: Client requested UTXO that was already spent!"); + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + + errorJSON.addProperty("code", -5); + errorJSON.addProperty("message", "Invalid or non-wallet transaction ID (already spent)"); + response.add("error", errorJSON); + break; + } + } + + // assemble result + JsonObject resultJSON = new JsonObject(); + resultJSON.addProperty("confirmations", count); + resultJSON.add("value", value); + resultJSON.add("scriptPubKey", scriptPubKey); + resultJSON.addProperty("coinbase", false); + + response.add("result", resultJSON); + response.add("error", JsonNull.INSTANCE); + } catch (Exception e) { + LOGGER.warning("[http-server-handler] ERROR: Error while parsing transaction!"); + LOGGER.warning("[http-server-handler] Error parsing transaction in gettxout for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1010); + errorJSON.addProperty("message", "Error while parsing transaction"); + + response.add("error", errorJSON); + } + + break; + } + case "getnewaddress": { + if (params.size() != 0) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: getnewaddress"); + + response.add("error", errorJSON); + break; + } + + AddressBalance newAddress = coin.generateAddress(true); + response.addProperty("result", newAddress.getAddress().toBase58()); + response.add("error", JsonNull.INSTANCE); + break; + } + case "importprivkey": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: importprivkey privatekey\n\nNOTE: Key's are not persistent!\n\nprivatekey (string, required)"); + + response.add("error", errorJSON); + break; + } + + coin.importPrivateKey(params.get(0).getAsString()); + response.addProperty("result", ""); + response.add("error", JsonNull.INSTANCE); + break; + } + case "dumpprivkey": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: dumpprivkey address\n\naddress (string, required)"); + + response.add("error", errorJSON); + break; + } + + AddressBalance addressBalance = coin.getAddress(params.get(0).getAsString()); + + if (addressBalance == null) { + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -4); + errorJSON.addProperty("message", "Address does not exist"); + + response.add("result", JsonNull.INSTANCE); + response.add("error", errorJSON); + + break; + } + + response.addProperty("result", addressBalance.getPrivateKey().toBase58()); + response.add("error", JsonNull.INSTANCE); + break; + } + case "signmessage": { + if (params.size() != 2) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: signmessage address message\n\naddress (string, required) - The address whose private key to use to sign the message\nmessage (string, required) - The message to sign"); + + response.add("error", errorJSON); + break; + } + + String addr = params.get(0).getAsString(); + String message = params.get(1).getAsString(); + + AddressBalance address = coin.getAddressBalance(addr); + if (address == null) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -5); + errorJSON.addProperty("message", "Invalid or non-wallet address"); + + response.add("error", errorJSON); + break; + } + + ECKey key = address.getPrivateKey().getKey(); + String signatureB64 = signMessage(key, message); + + response.addProperty("result", signatureB64); + response.add("error", JsonNull.INSTANCE); + break; + } + case "verifymessage": { + if (params.size() != 3) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: verifymessage address signature message\n\naddress (string, required) - The address whose private key to use to sign the message\nsignature (string, required) - The signature to verify, base64 encoded\nmessage (string, required) - The message to sign"); + + response.add("error", errorJSON); + break; + } + + String addr = params.get(0).getAsString(); + String signatureB64 = params.get(1).getAsString(); + String message = params.get(2).getAsString(); + + boolean verified = false; + + try { + ECKey key = signedMessageToKey(signatureB64, message); + verified = verifyMessage(key, signatureB64, message); + if (!verified) + throw new SignatureException("Signature was not verified."); + + String derivedAddr = LegacyAddress.fromKey(coin.getNetworkParameters(), key).toBase58(); + if (!addr.equals(derivedAddr)) { + LOGGER.warning("[http-server-handler] ERROR: Addresses do not match! Failing."); + verified = false; + } + } catch (Exception e) { + LOGGER.warning("[http-server-handler] Error while verifying signature! Invalid signature?"); + LOGGER.warning("[http-server-handler] Error verifying message for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + + response.addProperty("result", verified); + response.add("error", JsonNull.INSTANCE); + break; + } + + response.addProperty("result", verified); + response.add("error", JsonNull.INSTANCE); + break; + } + case "sendtransaction": { + if (params.size() != 2) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: sendtransaction address amount\n\naddress (string, required)\namount (number, required)"); + + response.add("error", errorJSON); + break; + } + + String address; + double amount; + try { + address = params.get(0).getAsString(); + amount = params.get(1).getAsDouble(); + } catch (RuntimeException e) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Error parsing JSON!"); + response.add("error", errorJSON); + + LOGGER.warning("[http-server-handler] Error parsing JSON in sendtransaction for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + break; + } + + Transaction transaction; + try { + transaction = WalletHelper.createTransactionSimple(coin.getTicker(), address, amount); + if (transaction == null) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -6); + errorJSON.addProperty("message", "Failed to create transaction"); + response.add("error", errorJSON); + break; + } + } catch (RuntimeException e) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -6); + errorJSON.addProperty("message", e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); + response.add("error", errorJSON); + + LOGGER.warning("[http-server-handler] Error creating transaction in sendtransaction for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + break; + } + + String rawTxHex = new String(Hex.encode(transaction.bitcoinSerialize())); + LOGGER.fine("[http-server-handler] sendtransaction: rawTx size=" + rawTxHex.length() + " bytes"); + LOGGER.finer("[http-server-handler] sendtransaction: rawTx hex=" + rawTxHex); + JsonObject txid = httpClient.sendRawTransaction(coin.getTicker(), rawTxHex); + if (txid == null || txid.has("error") && !txid.get("error").isJsonNull()) { + int code = -1; + String errorMsg = "Error sending transaction!"; + if (txid != null) { + JsonElement errorNode = txid.get("error"); + if (errorNode.isJsonPrimitive()) { + code = errorNode.getAsInt(); + } else if (errorNode.isJsonObject()) { + JsonObject errObj = errorNode.getAsJsonObject(); + if (errObj.has("code")) code = errObj.get("code").getAsInt(); + if (errObj.has("message")) errorMsg = errObj.get("message").getAsString(); + } + } + LOGGER.warning("[http-server-handler] sendrawtransaction failed for " + CoinTickerUtils.tickerToString(coin.getTicker()) + " code=" + code + " message=" + errorMsg); + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", code); + errorJSON.addProperty("message", errorMsg); + response.add("error", errorJSON); + break; + } + + WalletHelper.setAsSpent(coin.getTicker(), transaction, true); + + if (txid.has("result")) { + response.add("result", txid.get("result")); + response.add("error", JsonNull.INSTANCE); + + break; + } + + response.add("result", txid); + response.add("error", JsonNull.INSTANCE); + break; + } + case "version": { + response.addProperty("result", Version.CLIENT_VERSION); + response.add("error", JsonNull.INSTANCE); + break; + } + case "validateaddress": { + if (params.size() != 1) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1); + errorJSON.addProperty("message", "Usage: validateaddress address\n\naddress (string, required) - The address to validate."); + + response.add("error", errorJSON); + break; + } + + JsonObject resultJSON = new JsonObject(); + String address = params.get(0).getAsString(); + + boolean isValidAddress = Utility.isValidAddress(coin.getNetworkParameters(), address); + + resultJSON.addProperty("isvalid", isValidAddress); + resultJSON.addProperty("address", address); + + boolean isP2SH = false; + String scriptPubKey = ""; + if (isValidAddress) { + Address parsed; + try { + parsed = LegacyAddress.fromBase58(coin.getNetworkParameters(), address); + } catch (AddressFormatException e) { + // Bech32/segwit: derive the witness scriptPubKey + // (OP_ ) instead of crashing — + // isValidAddress legitimately accepted this address. + SegwitAddress segwit = SegwitAddress.fromBech32(coin.getNetworkParameters(), address); + byte[] program = segwit.getWitnessProgram(); + int version = segwit.getWitnessVersion(); + String opVersion = (version == 0) ? "00" + : Integer.toHexString(0x50 + version); + String pushOpCode = String.format("%02x", program.length); + scriptPubKey = opVersion + pushOpCode + + new String(Hex.encode(program)); + parsed = null; + } + if (parsed != null && isP2SHAddress(address)) { + isP2SH = true; + + TransactionOutput output = new TransactionOutput(coin.getNetworkParameters(), null, Coin.valueOf(0), parsed); + scriptPubKey = new String(Hex.encode(output.getScriptPubKey().getProgram())); + } + } + + resultJSON.addProperty("scriptPubKey", scriptPubKey); + resultJSON.addProperty("isscript", isP2SH); + + response.add("result", resultJSON); + response.add("error", JsonNull.INSTANCE); + break; + } + case "ping": { + response.addProperty("result", 1); + response.add("error", JsonNull.INSTANCE); + break; + } + case "help": { + String helpString = "JSON-RPC server for " + CoinTickerUtils.tickerToString(coin.getTicker()) + "\n" + + "This JSON-RPC server is served by " + CoinInstance.getVersionString() + "\n" + + "\n" + + "help - This command help.\n" + + "version - Get version\n" + + "\n=====Blockchain=====\n" + + "gettxout - Get info about an unspent transaction output\n" + + "\n=====Network=====\n" + + "ping - Lightweight liveness probe (result 1 if RPC listener alive)\n" + + "getinfo - Get information such as balances, protocol version, and more.\n" + + "getblockcount - Get block count\n" + + "getnetworkinfo - Get network information\n" + + "getfees - Get fee per byte and minimum transaction fee\n" + + "getrawmempool - Get raw mempool\n" + + "getblockchaininfo - Get blockchain info\n" + + "getblockhash - Get the hash of a block at a given height\n" + + "getblock - Get a block's JSON representation given its hash\n" + + "\n=====Wallet=====\n" + + "listunspent - Get all UTXOs in the wallet\n" + + "listtransactions - Get all transactions in the wallet\n" + + "getnewaddress - Generate a new address\n" + + "gettransaction - Get a transaction given its TXID\n" + + "getaddressesbyaccount - Get addresses belonging to a given account. The only account available is 'main' which contains all addresses.\n" + + "importprivkey - Import an address given it's privkey\n" + + "dumpprivkey
- Dump an addresses private key\n" + + "\n=====Utilities=====\n" + + "signmessage
- Sign a message with a given address' private key\n" + + "verifymessage
- Verify a signature for a message signed by a given address\n" + + "validateaddress
- Validate a given address\n" + + "reloadconfig - Reload configuration for the specified coin ticker\n" + + "sendtransaction
- Create and broadcast a signed transaction to the network\n" + + "\n=====Raw Transactions=====\n" + + "getrawtransaction [verbose] - Get a transaction's serialized hex (JSON when verbose)\n" + + "createrawtransaction - Create a raw transaction given inputs and outputs in JSON format. For more info, run createrawtransaction with no arguments.\n" + + "decoderawtransaction - Get a raw transaction's JSON representation\n" + + "signrawtransaction - Sign a raw transaction\n" + + "sendrawtransaction - Broadcast a signed raw transaction to the network\n"; + + response.addProperty("result", helpString); + response.add("error", JsonNull.INSTANCE); + break; + } + default: { + JsonObject methodNotFound = new JsonObject(); + methodNotFound.addProperty("code", -32601); + methodNotFound.addProperty("message", "Method not found."); + response.add("error", methodNotFound); + response.add("result", JsonNull.INSTANCE); + break; + } + } + + return response; + } + + private String canonicalizeASM(String asm) { + return asm + .replaceAll("DUP", "OP_DUP") + .replaceAll("HASH160", "OP_HASH160") + .replaceAll("EQUALVERIFY", "OP_EQUALVERIFY") + .replaceAll("CHECKSIG", "OP_CHECKSIG") + .replaceAll("RETURN", "OP_RETURN") + .replaceAll("PUSHDATA", "") + .replaceAll("\\[", "") + .replaceAll("]", "") + .replaceAll("\\([0-9]+\\)", ""); + } + + /** + * Returns the coin-specific signed-message header prefix, or null when the + * coin has no mapping. Package-private static for testability. + */ + static String signedMessageHeader(CoinTicker ticker) { + if (ticker == null) + return null; + switch (ticker) { + case BLOCKNET: + return "Blocknet Signed Message:\n"; + case BITCOIN: + case BITCOIN_CASH: + return "Bitcoin Signed Message:\n"; + case LITECOIN: + return "Litecoin Signed Message:\n"; + case PIVX: + return "DarkNet Signed Message:\n"; + case DASHCOIN: + return "DarkCoin Signed Message:\n"; + case UNOBTANIUM: + return "Unobtanium Signed Message:\n"; + case PKOIN: + return "Pocketcoin Signed Message:\n"; + case DIGIBYTE: + return "DigiByte Signed Message:\n"; + case RAVENCOIN: + return "Raven Signed Message:\n"; + case DOGECOIN: + return "Dogecoin Signed Message:\n"; + case SYSCOIN: + return "Syscoin Signed Message:\n"; + default: + LOGGER.warning("[http-server-handler] ERROR: Unsupported coin. This should never happen."); + return null; + } + } + + private byte[] formatMessageForSigning(String message) { + String header = signedMessageHeader(coin.getTicker()); + + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] headerBytes = header.getBytes(StandardCharsets.UTF_8); + bos.write(headerBytes.length); + bos.write(headerBytes); + byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); + VarInt size = new VarInt(messageBytes.length); + bos.write(size.encode()); + bos.write(messageBytes); + return bos.toByteArray(); + } catch (IOException e) { + LOGGER.warning("[http-server-handler] Error formatting message for signing for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + } + + return null; + } + + private String signMessage(ECKey key, String message) { + byte[] formatted = formatMessageForSigning(message); + Preconditions.checkNotNull(formatted, "Formatted message is null"); + Sha256Hash hash = Sha256Hash.twiceOf(formatted); + ECKey.ECDSASignature signature = key.sign(hash); + byte recoveryId = -1; + for (int i = 0; i < 4; i++) { + ECKey k = ECKey.recoverFromSignature(i, signature, hash, key.isCompressed()); + if (k != null && Arrays.equals(k.getPubKey(), key.getPubKey())) { + recoveryId = (byte) i; + break; + } + } + if (recoveryId == -1) + throw new IllegalStateException("Recovery ID is invalid."); + + byte[] sigData = new byte[65]; + byte headerByte = (byte) (recoveryId + 27 + (key.isCompressed() ? 4 : 0)); + sigData[0] = headerByte; + System.arraycopy(Utils.bigIntegerToBytes(signature.r, 32), 0, sigData, 1, 32); + System.arraycopy(Utils.bigIntegerToBytes(signature.s, 32), 0, sigData, 33, 32); + + return new String(Base64.encode(sigData)); + } + + private ECKey signedMessageToKey(String signatureB64, String message) throws SignatureException { + byte[] signatureEncoded; + try { + signatureEncoded = Base64.decode(signatureB64); + } catch (RuntimeException e) { + throw new SignatureException("Could not decode base64", e); + } + + if (signatureEncoded.length < 65) + throw new SignatureException("Signature truncated, expected 65 bytes and got " + signatureEncoded.length); + int header = signatureEncoded[0] & 0xFF; + + if (header < 27 || header > 34) + throw new SignatureException("Header byte out of range: " + header); + + BigInteger r = new BigInteger(1, Arrays.copyOfRange(signatureEncoded, 1, 33)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(signatureEncoded, 33, 65)); + ECKey.ECDSASignature sig = new ECKey.ECDSASignature(r, s); + byte[] messageBytes = formatMessageForSigning(message); + Preconditions.checkNotNull(messageBytes, "Message bytes are null."); + + Sha256Hash messageHash = Sha256Hash.twiceOf(messageBytes); + boolean compressed = false; + if (header >= 31) { + compressed = true; + header -= 4; + } + int recId = header - 27; + ECKey key = ECKey.recoverFromSignature(recId, sig, messageHash, compressed); + if (key == null) + throw new SignatureException("Could not recover public key from signature"); + return key; + } + + private boolean verifyMessage(ECKey key, String signatureB64, String message) { + boolean verified = false; + try { + ECKey k = signedMessageToKey(signatureB64, message); + if (Arrays.equals(k.getPubKey(), key.getPubKey())) + verified = true; + } catch (SignatureException e) { + LOGGER.warning("[http-server-handler] Error verifying message for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + } + + return verified; + } + + private boolean isP2SHAddress(String address) { + byte[] versionAndDataBytes = Base58.decodeChecked(address); + int version = versionAndDataBytes[0] & 0xFF; + return coin.getNetworkParameters().getP2SHHeader() == version; + } + + private void getScriptType(JsonObject scriptPubKey, Script.ScriptType type) { + String typeStr = "unknown"; + + switch (type) { + case P2PKH: + typeStr = "pubkeyhash"; + break; + case P2SH: + typeStr = "scripthash"; + break; + default: + break; + } + + scriptPubKey.addProperty("type", typeStr); + } + + private UTXO getUtxo(Sha256Hash txid, long vout) { + for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { + for (UTXO utxo : addressBalance.getUtxos()) { + if (utxo.createUTXO().getHash().equals(txid) && utxo.getVout() == vout) { + return utxo; + } else { + LOGGER.finer("[http-server-handler] DEBUG: UTXO " + utxo.createUTXO().getHash().toString() + " does not equal " + txid.toString()); + } + } + } + + return null; + } + + private ECKey getSigningKey(Sha256Hash txid, long vout) { + for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { + for (UTXO utxo : addressBalance.getUtxos()) { + if (utxo.createUTXO().getHash().equals(txid) && utxo.getVout() == vout) { + return addressBalance.getPrivateKey().getKey(); + } else { + LOGGER.finer("[http-server-handler] DEBUG: UTXO " + utxo.createUTXO().getHash().toString() + " does not equal " + txid.toString()); + } + } + } + + return null; + } + + private void getInvalidTxResponse(JsonObject response, Exception e) { + LOGGER.warning("[http-server-handler] Error decoding raw tx for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1007); + errorJSON.addProperty("message", "Error decoding raw tx. Invalid transaction?"); + + response.add("error", errorJSON); + } + + private void txConstructionError(JsonObject response, Exception e, String s) { + LOGGER.warning("[http-server-handler] Error constructing transaction for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1006); + errorJSON.addProperty("message", s); + response.add("error", errorJSON); + } + + private boolean isXRouterConfigInvalid(JsonObject response, BlocknetPeer blocknetPeer) { + if (blocknetPeer.getxRouterConfiguration() == null) { + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1005); + errorJSON.addProperty("message", "Server has not received XRouter configuration from node."); + response.add("error", errorJSON); + + return true; + } + return false; + } + + private void getXRouterResponse(JsonObject response, CountDownLatch latch, AtomicReference xRouterResult, BlocknetPeer blocknetPeer) { + int timeoutPeriod = blocknetPeer.getxRouterConfiguration().getTimeout(); + + try { + if (timeoutPeriod == 0) { + latch.await(10, TimeUnit.SECONDS); + } else { + latch.await(timeoutPeriod, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + LOGGER.warning("[http-server-handler] Interrupted waiting for XRouter response for " + CoinTickerUtils.tickerToString(coin.getTicker()) + ", " + e.getMessage()); + } + + if (xRouterResult.get() == null || xRouterResult.get().isEmpty()) { + LOGGER.finer("[http-server-handler] ERROR: XRouter request timed out or errored! Timeout period = " + timeoutPeriod); + + response.add("result", JsonNull.INSTANCE); + JsonObject errorJSON = new JsonObject(); + errorJSON.addProperty("code", -1004); + errorJSON.addProperty("message", "XRouter request timed out"); + + response.add("error", errorJSON); + } else { + JsonObject xRouterResObj = null; + if (isValidJSON(xRouterResult.get())) { + xRouterResObj = new Gson().fromJson(xRouterResult.get(), JsonObject.class); + } + + if (xRouterResObj != null && xRouterResObj.has("result")) { + if (xRouterResObj.has("code")) + response.add("code", xRouterResObj.get("code")); + if (xRouterResObj.has("error")) + response.add("error", xRouterResObj.get("error")); + response.add("result", xRouterResObj.get("result")); + } else { + if (xRouterResObj != null) { + response.add("result", xRouterResObj); + } else { + response.addProperty("result", xRouterResult.get()); + } + + response.add("error", JsonNull.INSTANCE); + } + } + } + + private boolean isValidJSON(String content) { + try { + new Gson().fromJson(content, JsonObject.class); + } catch (JsonParseException jsExcp) { + try { + new Gson().fromJson(content, JsonArray.class); + } catch (JsonParseException jsExcp1) { + return false; + } + } + return true; + } + + private boolean writeResponse(ChannelHandlerContext ctx, FullHttpResponse httpResponse, FullHttpRequest request) { + boolean keepAlive = false; + if (request != null) + keepAlive = HttpUtil.isKeepAlive(request); + + httpResponse.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json"); + httpResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, httpResponse.content().readableBytes()); + httpResponse.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); + httpResponse.headers().set(HttpHeaderNames.SERVER, CoinInstance.getVersionString()); + + LOGGER.finer("[http-server-handler] Writing response to channel. Keep alive? " + keepAlive); + LOGGER.finer("[http-server-handler] Response content: " + httpResponse.content().toString(CharsetUtil.UTF_8)); + ctx.write(httpResponse); + + return keepAlive; + } + + private static void send100Continue(ChannelHandlerContext ctx) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE); + ctx.write(response); + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/api/http/server/HTTPServerInitializer.java b/src/main/java/io/xlite/daemon/app/net/api/http/server/HTTPServerInitializer.java new file mode 100644 index 0000000..0193492 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/api/http/server/HTTPServerInitializer.java @@ -0,0 +1,34 @@ +package io.xlite.daemon.app.net.api.http.server; + +import io.xlite.daemon.app.net.CoinInstance; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpRequestDecoder; +import io.netty.handler.codec.http.HttpResponseEncoder; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; + +public class HTTPServerInitializer extends ChannelInitializer { + + private CoinInstance coin; + + public HTTPServerInitializer(CoinInstance coin) { + this.coin = coin; + } + + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline pipeline = ch.pipeline(); + + pipeline.addLast(new WriteTimeoutHandler(30)); + pipeline.addLast(new ReadTimeoutHandler(30)); + pipeline.addLast(new HttpRequestDecoder()); + pipeline.addLast(new HttpResponseEncoder()); + pipeline.addLast(new HttpObjectAggregator(100000000)); + pipeline.addLast(new HTTPServerHandler(coin)); + pipeline.addLast(new ExceptionHandler()); + } + +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/bitcoin/BitcoinNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/bitcoin/BitcoinNetworkParametersLegacy.java new file mode 100644 index 0000000..f2db429 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/bitcoin/BitcoinNetworkParametersLegacy.java @@ -0,0 +1,19 @@ +package io.xlite.daemon.app.net.protocols.bitcoin; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.params.MainNetParams; + +public class BitcoinNetworkParametersLegacy extends MainNetParams implements HasFeeParams { + + public BitcoinNetworkParametersLegacy() { + super(); + } + + public long getFeePerByte() { + return 60; + } + + public long getMinTxFee() { + return 12000; + } +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/bitcoincash/BitcoinCashNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/bitcoincash/BitcoinCashNetworkParametersLegacy.java new file mode 100644 index 0000000..c31f887 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/bitcoincash/BitcoinCashNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.bitcoincash; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class BitcoinCashNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public BitcoinCashNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return PAYMENT_PROTOCOL_ID_MAINNET; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return MAX_MONEY; + } + + @Override + public Coin getMinNonDustOutput() { + return Transaction.MIN_NONDUST_OUTPUT; + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "BCH"); + } + + @Override + public String getUriScheme() { + return "bch:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(final ProtocolVersion version) { + return version.getBitcoinProtocolVersion(); + } + + @Override + public int getAddressHeader() { + return 0; + } + + @Override + public int getP2SHHeader() { + return 5; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 128; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 210240; + } + + @Override + public int getInterval() { + return INTERVAL; + } + + @Override + public String getId() { + return "BCH"; + } + + public long getFeePerByte() { + return 2; + } + + public long getMinTxFee() { + return 500; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetBlockingClient.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetBlockingClient.java new file mode 100644 index 0000000..aa852fa --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetBlockingClient.java @@ -0,0 +1,123 @@ +package io.xlite.daemon.app.net.protocols.blocknet; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import org.bitcoinj.core.Context; +import org.bitcoinj.net.MessageWriteTarget; +import org.bitcoinj.net.StreamConnection; + +import javax.annotation.Nullable; +import javax.net.SocketFactory; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.util.Set; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class BlocknetBlockingClient implements MessageWriteTarget { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private static final int BUFFER_SIZE_LOWER_BOUND = 4096; + private static final int BUFFER_SIZE_UPPER_BOUND = 65536; + + private Socket socket; + private volatile boolean closeRequested = false; + private SettableFuture connectFuture; + + public BlocknetBlockingClient(SocketAddress serverAddress, StreamConnection connection, int connectTimeoutMillis, SocketFactory socketFactory, @Nullable Set clientSet) throws IOException { + connectFuture = SettableFuture.create(); + + connection.setWriteTarget(this); + socket = socketFactory.createSocket(); + final Context context = Context.get(); + Thread t = new Thread(() -> { + Context.propagate(context); + if (clientSet != null) + clientSet.add(BlocknetBlockingClient.this); + try { + socket.connect(serverAddress, connectTimeoutMillis); + connection.connectionOpened(); + connectFuture.set(serverAddress); + InputStream stream = socket.getInputStream(); + runReadLoop(stream, connection); + } catch (Exception e) { + if (!closeRequested) { + LOGGER.warning("[blocknet] Error opening/reading connection with " + serverAddress.toString() + ", " + e.getMessage()); + connectFuture.setException(e); + } + } finally { + try { + if (!socket.isClosed()) + socket.close(); + } catch (IOException e1) { + // At this point there isn't much we can do, and we can probably assume the channel is closed + } + if (clientSet != null) + clientSet.remove(BlocknetBlockingClient.this); + connection.connectionClosed(); + } + }); + t.setName("Blocknet network thread - " + serverAddress); + t.setDaemon(true); + t.start(); + } + + private static void runReadLoop(InputStream inputStream, StreamConnection connection) throws Exception { + ByteBuffer dbuf = ByteBuffer.allocateDirect(Math.min(Math.max(connection.getMaxMessageSize(), BUFFER_SIZE_LOWER_BOUND), BUFFER_SIZE_UPPER_BOUND)); + byte[] readBuff = new byte[dbuf.capacity()]; + + while (true) { + // TODO Kill the message duplication here + if (!(dbuf.remaining() > 0 && dbuf.remaining() <= readBuff.length)) { + throw new IllegalStateException(); + } + int read = inputStream.read(readBuff, 0, Math.max(1, Math.min(dbuf.remaining(), inputStream.available()))); + if (read == -1) + return; + dbuf.put(readBuff, 0, read); + + dbuf.flip(); + + int bytesConsumed = connection.receiveBytes(dbuf); + if (dbuf.position() != bytesConsumed) { + throw new IllegalStateException("Buffer did not stop reading at the correct location."); + } + + dbuf.compact(); + } + } + + @Override + public void closeConnection() { + try { + closeRequested = true; + socket.close(); + } catch (IOException e) { + LOGGER.warning("[blocknet] Error closing socket" + e.getMessage()); + } + } + + @Override + public synchronized ListenableFuture writeBytes(byte[] bytes) throws IOException { + try { + OutputStream out = socket.getOutputStream(); + out.write(bytes); + out.flush(); + return Futures.immediateFuture(null); + } catch (IOException e) { + LOGGER.warning("[blocknet] Error writing bytes to socket" + e.getMessage()); + closeConnection(); + throw e; + } + } + + public SettableFuture getConnectFuture() { + return connectFuture; + } +} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetBlockingClientManager.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetBlockingClientManager.java similarity index 95% rename from src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetBlockingClientManager.java rename to src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetBlockingClientManager.java index 3f25589..ba6ce57 100644 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetBlockingClientManager.java +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetBlockingClientManager.java @@ -1,4 +1,4 @@ -package io.cloudchains.app.net.protocols.blocknet; +package io.xlite.daemon.app.net.protocols.blocknet; import com.google.common.util.concurrent.AbstractIdleService; import com.google.common.util.concurrent.ListenableFuture; @@ -45,7 +45,8 @@ public void setConnectTimeoutMillis(int connectTimeoutMillis) { } @Override - protected void startUp() throws Exception { } + protected void startUp() throws Exception { + } @Override protected void shutDown() throws Exception { diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetNetworkParameters.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetNetworkParameters.java new file mode 100644 index 0000000..c65320e --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetNetworkParameters.java @@ -0,0 +1,169 @@ +package io.xlite.daemon.app.net.protocols.blocknet; + +import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.net.HasFeeParams; +import io.xlite.daemon.app.net.xrouter.XRouterMessageSerializer; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +import java.math.BigInteger; + +public class BlocknetNetworkParameters extends BlocknetParameters implements HasFeeParams { + + public BlocknetNetworkParameters() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + + } + + + @Override + public Sha256Hash getGenesisBlockHash() { + return Sha256Hash.wrap("00000eb7919102da5a07dc90905651664e6ebf0811c28f06573b9a0fd84ab7b8"); + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(43199500).times(Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Coin.valueOf(5500); + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "BLOCK"); + } + + @Override + public String getUriScheme() { + return "blocknetdx:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BlocknetSerializer getSerializer(boolean parseRetain) { + return new BlocknetSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70712; + } + + /*@Override + public Block getGenesisBlock() { + return genesisBlock; + }*/ + + @Override + public int getSubsidyDecreaseBlockCount() { + return 210000; + } + + @Override + public byte[] getAlertSigningKey() { + return Hex.decode("0415758705177c87c35dadf7ebf66e93ecc2710253bbac955e695664011fa39ff29a84fa21ae9e203a43debb487170c143ab6eaffe4fa3b12e162d8a6d4da87395"); + } + + @Override + public int getMajorityEnforceBlockUpgrade() { + return 750; + } + + @Override + public int getMajorityRejectBlockOutdated() { + return 950; + } + + @Override + public int getMajorityWindow() { + return 1000; + } + + @Override + public int getPort() { + return 41412; + } + + @Override + public long getPacketMagic() { + return 0xA1A0A2A3; + } + + @Override + public String[] getDnsSeeds() { + int nodeCount = 5; + + String[] dnsSeeds = new String[nodeCount]; + + for (int i = 0; i < nodeCount; i++) + dnsSeeds[i] = "node-" + i + ".cloudchainsinc.com"; + + return dnsSeeds; + } + + @Override + public int getInterval() { + return 1; + } + + @Override + public int getAddressHeader() { + return 26; + } + + @Override + public int getP2SHHeader() { + return 28; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 154; + } + + @Override + public BigInteger getMaxTarget() { + return Utils.decodeCompactBits(0x1E0FFFFF); + } + + @Override + public int getTargetTimespan() { + return 60; + } + + @Override + public String getId() { + return "BLOCK"; + } + + @Override + public XRouterMessageSerializer getXRouterMessageSerializer(boolean parseRetain) { + return new XRouterMessageSerializer(parseRetain, this); + } + + public long getFeePerByte() { + return 20; + } + + public long getMinTxFee() { + return 10000; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPacketHeader.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPacketHeader.java new file mode 100644 index 0000000..084268a --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPacketHeader.java @@ -0,0 +1,68 @@ +package io.xlite.daemon.app.net.protocols.blocknet; + +import org.bitcoinj.core.BitcoinSerializer; +import org.bitcoinj.core.Message; +import org.bitcoinj.core.ProtocolException; +import org.bitcoinj.core.Utils; + +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; + +public class BlocknetPacketHeader extends BitcoinSerializer.BitcoinPacketHeader { + + public static final int HEADER_LENGTH = 20; + + private String command; + private byte[] checksum; + private int length; + + public BlocknetPacketHeader(ByteBuffer in) throws ProtocolException, BufferUnderflowException { + super(in); + in.position(in.position() - BitcoinSerializer.BitcoinPacketHeader.HEADER_LENGTH); + byte[] header = new byte[HEADER_LENGTH]; + in.get(header, 0, header.length); + + int cursor = 0; + + for (; header[cursor] != 0x00 && cursor < 12; cursor++); + + byte[] commandBytes = new byte[cursor]; + System.arraycopy(header, 0, commandBytes, 0, cursor); + cursor = 12; + + command = new String(commandBytes).trim(); +// LOGGER.finer("[blocknet-header] Retrieved command: " + command); + + length = (int) Utils.readUint32(header, cursor); +// LOGGER.finer("[blocknet-header] Retrieved length: " + length); + cursor += 4; + + if (length > Message.MAX_SIZE || length < 0) { + throw new ProtocolException("Message too large or negative length: " + length); + } + + checksum = new byte[4]; + System.arraycopy(header, cursor, checksum, 0, 4); +// LOGGER.finer("[blocknet-header] Retrieved checksum: " + new String(Hex.encode(checksum))); + } + + public String getCommand() { + return command; + } + + public void setChecksum(byte[] checksum) { + this.checksum = checksum; + } + + public byte[] getChecksum() { + return checksum; + } + + public void setLength(int length) { + this.length = length; + } + + public int getLength() { + return length; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetParameters.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetParameters.java new file mode 100644 index 0000000..2e05e6d --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetParameters.java @@ -0,0 +1,15 @@ +package io.xlite.daemon.app.net.protocols.blocknet; + +import io.xlite.daemon.app.net.xrouter.XRouterMessageSerializer; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Sha256Hash; + +public abstract class BlocknetParameters extends NetworkParameters { + + public abstract XRouterMessageSerializer getXRouterMessageSerializer(boolean parseRetain); + + public abstract Sha256Hash getGenesisBlockHash(); + + @Override + public abstract BlocknetSerializer getSerializer(boolean parseRetain); +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPeer.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPeer.java new file mode 100644 index 0000000..944ac22 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPeer.java @@ -0,0 +1,749 @@ +package io.xlite.daemon.app.net.protocols.blocknet; + +import com.google.common.base.Function; +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import io.xlite.daemon.app.Version; +import io.xlite.daemon.app.net.protocols.blocknet.listeners.*; +import io.xlite.daemon.app.net.protocols.blocknet.messages.VersionMessageImpl; +import io.xlite.daemon.app.net.xrouter.XRouterCommandUtils; +import io.xlite.daemon.app.net.xrouter.XRouterInitialMessagesSentListener; +import io.xlite.daemon.app.net.xrouter.XRouterMessage; +import io.xlite.daemon.app.net.xrouter.XRouterMessageSerializer; +import io.xlite.daemon.app.util.XRouterConfiguration; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.ListenerRegistration; +import org.bitcoinj.utils.Threading; +import org.bitcoinj.wallet.Wallet; +import org.json.JSONObject; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.GuardedBy; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.channels.NotYetConnectedException; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class BlocknetPeer extends PeerSocketHandler { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private final ReentrantLock lock = Threading.lock("BlocknetPeer"); + + private volatile boolean activePeer; + private volatile boolean hasRequiredPlugins; + + private volatile boolean pastConnectionSuccess; + + private BlocknetParameters params; + private BlocknetSerializer serializer; + private XRouterMessageSerializer xRouterMessageSerializer; + private AbstractBlockChain blockChain; + + private BlocknetSeed blocknetSeed; + private XRouterConfiguration xRouterConfiguration; + private final ArrayList pluginConfigurations = new ArrayList<>(); + private final AtomicBoolean haveConfig = new AtomicBoolean(false); + + + private Context context; + private CopyOnWriteArrayList messagesPendingReply = new CopyOnWriteArrayList<>(); + + private CopyOnWriteArrayList> initialMessagesSentListeners = new CopyOnWriteArrayList<>(); + private CopyOnWriteArrayList> preMessageReceivedEventListeners = new CopyOnWriteArrayList<>(); + private CopyOnWriteArrayList> peerConnectedEventListeners = new CopyOnWriteArrayList<>(); + private CopyOnWriteArrayList> blocksDownloadedEventListeners = new CopyOnWriteArrayList<>(); + private CopyOnWriteArrayList> disconnectedEventListeners = new CopyOnWriteArrayList<>(); + private CopyOnWriteArrayList> xRouterMessageListeners = new CopyOnWriteArrayList<>(); + + private volatile boolean downloadData; + + @GuardedBy("lock") + private boolean downloadBlockBodies; + + private static class GetDataRequest { + final Sha256Hash hash; + final SettableFuture future; + + public GetDataRequest(Sha256Hash hash, SettableFuture future) { + this.hash = hash; + this.future = future; + } + } + + private final CopyOnWriteArrayList getDataFutures = new CopyOnWriteArrayList<>(); + + @GuardedBy("lock") + private Sha256Hash lastGetBlocksBegin, lastGetBlocksEnd; + + private final CopyOnWriteArrayList pendingPings = new CopyOnWriteArrayList<>(); + private VersionMessage peerVersionMessage = null; + private final VersionMessage ourVersionMessage; + private CopyOnWriteArrayList wallets = new CopyOnWriteArrayList<>(); + private volatile int downloadTxDependencyDepth; + + private final SettableFuture connectionOpenFuture = SettableFuture.create(); + private final SettableFuture incomingVersionHandshakeFuture = SettableFuture.create(); + private final SettableFuture outgoingVersionHandshakeFuture = SettableFuture.create(); + private final SettableFuture incomingPingHandshakeFuture = SettableFuture.create(); + private boolean firstPingReceived = false; + + @SuppressWarnings({"UnstableApiUsage", "unchecked"}) + private final ListenableFuture versionHandshakeFuture = Futures.transform(Futures.allAsList(outgoingVersionHandshakeFuture, + incomingVersionHandshakeFuture, + incomingPingHandshakeFuture), + new Function, BlocknetPeer>() { + @Nullable + @Override + public BlocknetPeer apply(@Nullable List peers) { + if (peers == null) { + throw new NullPointerException("Peer list is null."); + } + + if (peers.size() != 2 || peers.get(0) != peers.get(1)) { + throw new IllegalStateException("Bad peer list state."); + } + + return peers.get(0); + } + }, Threading.SAME_THREAD); + + private FilteredBlock currentFilteredBlock; + private final HashSet pendingBlockDownloads = new HashSet<>(); + + @GuardedBy("lock") + @Nullable + private List awaitingFreshFilter; + + private static final int minProtocolVersion = 70712; + + private AtomicReference largeReadBuffer = new AtomicReference<>(); + private AtomicInteger largeReadBufferPos = new AtomicInteger(); + private AtomicReference header; + + protected BlocknetPeer(BlocknetParameters params, AbstractBlockChain chain, PeerAddress peerAddress, BlocknetSeed blocknetSeed) { + super(params, peerAddress); + + this.params = params; + this.serializer = this.params.getSerializer(false); + this.xRouterMessageSerializer = this.params.getXRouterMessageSerializer(false); + + this.blockChain = chain; + this.downloadData = chain != null; + this.downloadTxDependencyDepth = chain != null ? Integer.MAX_VALUE : 0; + this.peerAddress = peerAddress; + + this.blocknetSeed = blocknetSeed; + + this.versionHandshakeFuture.addListener(this::versionHandshakeComplete, Threading.SAME_THREAD); + + this.context = Context.getOrCreate(params); + + this.ourVersionMessage = new VersionMessageImpl(this.params, chain != null ? chain.getBestChainHeight() : 0); + this.ourVersionMessage.appendToSubVer(Version.CLIENT_TYPE, Version.CLIENT_VERSION, Version.CLIENT_COMMENTS); + + LOGGER.finer("[blocknet-peer] DEBUG: Our version message:"); + LOGGER.finer(this.ourVersionMessage.toString()); + + this.activePeer = true; + this.pastConnectionSuccess = false; + } + + @Override + public void connectionClosed() { + if (!activePeer) return; + + activePeer = false; + LOGGER.finer("[blocknet-peer] Connection with " + (getAddress() != null ? getAddress().toString() : "") + " closed. Notifying receivers."); + + for (final ListenerRegistration registration : disconnectedEventListeners) { + registration.executor.execute(() -> registration.listener.onPeerDisconnected(BlocknetPeer.this, 0)); + } + } + + @Override + public void connectionOpened() { + LOGGER.finer("[blocknet-peer] Connection open to " + (getAddress() != null ? getAddress().toString() : "") + ", sending version message."); + + sendMessage(ourVersionMessage); + connectionOpenFuture.set(this); + } + + @Override + protected void timeoutOccurred() { + super.timeoutOccurred(); + LOGGER.finer("[blocknet-peer] Timeout occurred."); + if (!connectionOpenFuture.isDone()) { + connectionClosed(); + } + } + + public void addPreMessageReceivedEventListener(BlocknetPreMessageReceivedEventListener listener) { + addPreMessageReceivedEventListener(Threading.SAME_THREAD, listener); + } + + private void addPreMessageReceivedEventListener(Executor executor, BlocknetPreMessageReceivedEventListener listener) { + preMessageReceivedEventListeners.add(new ListenerRegistration<>(listener, executor)); + } + + public void addConnectedEventListener(BlocknetPeerConnectedEventListener listener) { + addConnectedEventListener(Threading.SAME_THREAD, listener); + } + + public void addConnectedEventListener(Executor executor, BlocknetPeerConnectedEventListener listener) { + peerConnectedEventListeners.add(new ListenerRegistration<>(listener, executor)); + } + + public void addBlocksDownloadedEventListener(BlocknetOnBlocksDownloadedEventListener listener) { + addBlocksDownloadedEventListener(Threading.SAME_THREAD, listener); + } + + private void addBlocksDownloadedEventListener(Executor executor, BlocknetOnBlocksDownloadedEventListener listener) { + blocksDownloadedEventListeners.add(new ListenerRegistration<>(listener, executor)); + } + + public void addPeerDisconnectedEventListener(BlocknetPeerDisconnectedEventListener listener) { + addPeerDisconnectedEventListener(Threading.SAME_THREAD, listener); + } + + public void addPeerDisconnectedEventListener(Executor executor, BlocknetPeerDisconnectedEventListener listener) { + disconnectedEventListeners.add(new ListenerRegistration<>(listener, executor)); + } + + public void addXRouterMessageReceivedEventListener(BlocknetOnXRouterMessageReceivedListener listener) { + addXRouterMessageReceivedEventListener(Threading.SAME_THREAD, listener); + } + + private void addXRouterMessageReceivedEventListener(Executor executor, BlocknetOnXRouterMessageReceivedListener listener) { + xRouterMessageListeners.add(new ListenerRegistration<>(listener, executor)); + } + + public void removeXRouterMessageReceivedEventListener(BlocknetOnXRouterMessageReceivedListener listener) { + ListenerRegistration.removeFromList(listener, xRouterMessageListeners); + } + + public BlocknetOnXRouterMessageReceivedListener getListener(final String uuid, final AtomicReference response, final CountDownLatch latch) { + return new BlocknetOnXRouterMessageReceivedListener() { + @Override + public void onXRouterMessageReceived(XRouterMessage message, XRouterMessage original) { + if (message.getXRouterHeader().getUUID().equals(uuid)) { + response.set((String) message.getParsedData().get("reply")); + + latch.countDown(); + + removeXRouterMessageReceivedEventListener(this); + } + } + }; + } + + @Override + public ListenableFuture sendMessage(Message message) throws NotYetConnectedException { + lock.lock(); + try { + if (writeTarget == null) { + LOGGER.finer("[blocknet-peer] ERROR: Attempted to send message on non-connected socket."); + throw new NotYetConnectedException(); + } + } finally { + lock.unlock(); + } + + if (message instanceof XRouterMessage) { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + xRouterMessageSerializer.serialize(message, outputStream); + LOGGER.finer("[blocknet-peer] DEBUG: Sending XRouter message. Actual length (excluding network header) is " + (outputStream.size() - BlocknetPacketHeader.HEADER_LENGTH - 4) + " bytes."); + ListenableFuture future = writeTarget.writeBytes(outputStream.toByteArray()); + + messagesPendingReply.add((XRouterMessage) message); + LOGGER.finer("[blocknet-peer] DEBUG: Added UUID " + ((XRouterMessage) message).getXRouterHeader().getUUID() + " to pending reply list."); + return future; + } catch (IOException e) { + LOGGER.warning("[blocknet] Error serializing XRouter message" + e.getMessage()); + } + } else { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + serializer.serialize(message, outputStream); + return writeTarget.writeBytes(outputStream.toByteArray()); + } catch (IOException e) { + LOGGER.warning("[blocknet] Error serializing/sending non-XRouter message" + e.getMessage()); + } + } + + return Futures.immediateFuture(null); + } + + @Override + protected void processMessage(Message message) { + for (ListenerRegistration registration : preMessageReceivedEventListeners) { + if (registration.executor == Threading.SAME_THREAD) { + message = registration.listener.onPreMessageReceived(this, message); + } + + if (message == null) + break; + } + + if (message == null) + return; + + if (currentFilteredBlock != null && !(message instanceof Transaction)) { + endFilteredBlock(currentFilteredBlock); + currentFilteredBlock = null; + } + + if (!(message instanceof VersionMessage || message instanceof Ping || message instanceof VersionAck || (versionHandshakeFuture.isDone() && !versionHandshakeFuture.isCancelled()))) { + throw new ProtocolException("Received " + message.getClass().getSimpleName() + " before version handshake was complete."); + } + + if (message instanceof VersionMessage) { + processVersionMessage((VersionMessage) message); + } else if (message instanceof VersionAck) { + processVersionAck((VersionAck) message); + } else if (message instanceof Ping) { + LOGGER.finer("[blocknet-peer] Received ping message from " + getAddress().toString() + ", sending pong."); + processPing((Ping) message); + } else if (message instanceof RejectMessage) { + LOGGER.finer("[blocknet-peer] ERROR: Received rejection message from " + getAddress().toString() + ": " + message.toString()); + } else if (message instanceof XRouterMessage) { + processXRouterMessage((XRouterMessage) message); + } else { + LOGGER.finer("[blocknet-peer] Warning: Received unhandled message from " + getAddress().toString() + ": " + message.toString()); + } + + //TODO process other message types + } + + private void processPing(Ping pingMessage) throws ProtocolException { + sendMessage(new Pong(pingMessage.getNonce())); + + if (!firstPingReceived) { + firstPingReceived = true; + incomingPingHandshakeFuture.set(this); + } + } + + private void processVersionMessage(VersionMessage versionMessage) throws ProtocolException { + if (peerVersionMessage != null) { + throw new ProtocolException("Received more than one version message from this peer!"); + } + + peerVersionMessage = versionMessage; + + LOGGER.finer("[blocknet-peer] Received version message: " + peerVersionMessage.subVer + + ", version " + peerVersionMessage.clientVersion + + ", blocks=" + peerVersionMessage.bestHeight + + ", us=" + peerVersionMessage.receivingAddr); + + if (!peerVersionMessage.hasBlockChain() || (!params.allowEmptyPeerChain() && peerVersionMessage.bestHeight == 0)) { + LOGGER.finer("[blocknet-peer] ERROR: Peer has an empty blockchain while this network does not allow empty blockchains. Disconnecting."); + close(); + } + + if (peerVersionMessage.bestHeight < 0) { + LOGGER.finer("[blocknet-peer] ERROR: Peer reported bad blockchain height (" + peerVersionMessage.bestHeight + "). Disconnecting."); + close(); + } + + sendMessage(new VersionAck()); + LOGGER.finer("[blocknet-peer] Incoming version handshake complete."); + incomingVersionHandshakeFuture.set(this); + } + + private void processVersionAck(VersionAck versionAck) throws ProtocolException { + if (peerVersionMessage == null) { + throw new ProtocolException("Received version acknowledgement before version message."); + } + + if (!incomingVersionHandshakeFuture.isDone()) { + throw new ProtocolException("Received more than one version acknowledgement."); + } + + LOGGER.finer("[blocknet-peer] Outgoing version handshake complete."); + outgoingVersionHandshakeFuture.set(this); + } + + private void versionHandshakeComplete() { + setTimeoutEnabled(false); + for (final ListenerRegistration registration : peerConnectedEventListeners) { + registration.executor.execute(() -> registration.listener.onPeerConnected(BlocknetPeer.this, 1)); + } + + if (peerVersionMessage.clientVersion < minProtocolVersion) { + LOGGER.finer("[blocknet-peer] Peer's protocol version (" + peerVersionMessage.clientVersion + ") is lower than the minimum (" + minProtocolVersion + ")! Disconnecting."); + close(); + } + } + + private XRouterMessage getOriginalXRouterMessage(String uuid) { + for (XRouterMessage msg : messagesPendingReply) { + if (msg.getXRouterHeader().getUUID().equalsIgnoreCase(uuid)) + return msg; + } + + return null; + } + + private int removeUUIDFromPendingReplyList(String uuid) { + int removed = 0; + + for (XRouterMessage msg : messagesPendingReply) { + if (msg.getXRouterHeader().getUUID().equalsIgnoreCase(uuid)) { + messagesPendingReply.remove(msg); + removed++; + } + } + + return removed; + } + + private void processReply(XRouterMessage message) { + if (message.getXRouterHeader().getUUID().isEmpty()) { + LOGGER.finer("[blocknet-peer] ERROR: XRouter server sent back packet with blank UUID!"); + return; + } + + final XRouterMessage original = getOriginalXRouterMessage(message.getXRouterHeader().getUUID()); + if (original == null) { + LOGGER.finer("[blocknet-peer] ERROR: Unexpected UUID in reply message! Perhaps the server thinks we sent a packet that we didn't send?"); + throw new ProtocolException("Unexpected UUID in reply message"); + } + + int removed = removeUUIDFromPendingReplyList(message.getXRouterHeader().getUUID()); + if (removed != 1) { + LOGGER.finer("[blocknet-peer] Warning: Exception occurred while removing message from pending list! This may break things later on. Amount of messages removed = " + removed); + if (removed == 0) { + LOGGER.finer("[blocknet-peer] ERROR: Invalid UUID in reply message!"); + throw new ProtocolException("Invalid UUID in reply message"); + } + } + + LOGGER.finer("[blocknet-peer] XRouter pre-processing successful. Notifying listeners."); + for (ListenerRegistration registration : xRouterMessageListeners) { + if (registration.executor == Threading.SAME_THREAD) { + registration.executor.execute(() -> registration.listener.onXRouterMessageReceived(message, original)); + } + } + } + + private void processXRouterMessage(final XRouterMessage message) { + LOGGER.finer("processXRouterMessage() called."); + LOGGER.finer("This XRouter message's UUID is '" + message.getXRouterHeader().getUUID() + "'"); + + switch (XRouterCommandUtils.commandIdToString(message.getXRouterHeader().getCommand())) { + case "xrReply": //xrReply + case "xrConfigReply": { // xrConfigReply + processReply(message); + break; + } + default: { //xrInvalid or unexpected message + for (ListenerRegistration registration : xRouterMessageListeners) { + if (registration.executor == Threading.SAME_THREAD) { + registration.executor.execute(() -> registration.listener.onXRouterMessageReceived(message, null)); + } + } + break; + } + } + + } + + @GuardedBy("lock") + private void blockChainDownloadLocked(Sha256Hash toHash) { + if (!lock.isHeldByCurrentThread()) { + throw new IllegalStateException("Lock is not held by current thread."); + } + + List blockLocatorHashes = new ArrayList<>(51); + + if (blockChain == null) { + throw new NullPointerException("Blockchain object is null."); + } + + BlockStore blockStore = blockChain.getBlockStore(); + StoredBlock chainHead = blockChain.getChainHead(); + Sha256Hash chainHeadHash = chainHead.getHeader().getHash(); + + if (Objects.equals(chainHeadHash, lastGetBlocksBegin) || Objects.equals(toHash, lastGetBlocksEnd)) { + LOGGER.finer("[blocknet-peer] Ignoring dupliated request: chainHeadHash = " + chainHeadHash.toString() + ", toHash = " + toHash.toString()); + + for (Sha256Hash hash : pendingBlockDownloads) + LOGGER.finer("[blocknet-peer] Pending block download: " + hash.toString()); + + LOGGER.finer(Throwables.getStackTraceAsString(new Throwable())); + return; + } + + LOGGER.finer("[blocknet-peer] blockChainDownloadLocked(" + toHash.toString() + "): Current head = " + chainHeadHash.toString()); + + StoredBlock cursor = chainHead; + for (int i = 100; cursor != null && i > 0; i--) { + blockLocatorHashes.add(cursor.getHeader().getHash()); + try { + cursor = cursor.getPrev(blockStore); + } catch (BlockStoreException e) { + LOGGER.warning("[blocknet] Failed to walk blockchain while constructing locator" + e.getMessage()); + } + } + + if (cursor != null) + blockLocatorHashes.add(params.getGenesisBlockHash()); + + BlockLocator blockLocator = new BlockLocator(ImmutableList.copyOf(blockLocatorHashes)); + + lastGetBlocksBegin = chainHeadHash; + lastGetBlocksEnd = toHash; + + if (downloadBlockBodies) { + GetBlocksMessage getBlocksMessage = new GetBlocksMessage(params, blockLocator, toHash); + sendMessage(getBlocksMessage); + } else { + GetHeadersMessage getHeadersMessage = new GetHeadersMessage(params, blockLocator, toHash); + sendMessage(getHeadersMessage); + } + } + + private void endFilteredBlock(FilteredBlock filteredBlock) { + if (!downloadData) { + LOGGER.finer("[blocknet-peer] WARNING: [" + getAddress().toString() + "] Received block we did not ask for! Hash: " + filteredBlock.getHash().toString()); + return; + } + + if (blockChain == null) { + LOGGER.finer("[blocknet-peer] WARNING: Received a block, but a blockchain object was not configured!"); + return; + } + + pendingBlockDownloads.remove(filteredBlock.getBlockHeader().getHash()); + try { + lock.lock(); + + try { + if (awaitingFreshFilter != null) { + LOGGER.finer("[blocknet-peer] Discarding this block because we are waiting for a fresh filter. Hash: " + filteredBlock.getHash().toString()); + + awaitingFreshFilter.add(filteredBlock.getHash()); + return; + } else if (checkForFilterExhaustion(filteredBlock)) { + awaitingFreshFilter = new LinkedList<>(); + awaitingFreshFilter.add(filteredBlock.getHash()); + awaitingFreshFilter.addAll(blockChain.drainOrphanBlocks()); + return; + } + } finally { + lock.unlock(); + } + + if (blockChain.add(filteredBlock)) { + invokeOnBlocksDownloaded(filteredBlock.getBlockHeader(), filteredBlock); + } else { + lock.lock(); + try { + final Block orphanRoot = blockChain.getOrphanRoot(filteredBlock.getHash()); + if (orphanRoot == null) { + throw new NullPointerException("Orphan root is null."); + } + + blockChainDownloadLocked(orphanRoot.getHash()); + } finally { + lock.unlock(); + } + } + } catch (VerificationException e) { + LOGGER.warning("[blocknet] Block failed to properly verify" + e.getMessage()); + } catch (PrunedException e) { + LOGGER.finer("[blocknet-peer] Some data needed to handle this block was pruned! Hash: " + filteredBlock.getHash().toString()); + throw new RuntimeException(e); + } + } + + private boolean checkForFilterExhaustion(FilteredBlock filteredBlock) { + boolean exhausted = false; + for (Wallet wallet : wallets) { + exhausted |= wallet.checkForFilterExhaustion(filteredBlock); + } + return exhausted; + } + + private void invokeOnBlocksDownloaded(final Block block, @Nullable final FilteredBlock filteredBlock) { + if (blockChain == null) { + return; + } + + final int blocksLeft = Math.max(0, (int) peerVersionMessage.bestHeight - blockChain.getBestChainHeight()); + for (final ListenerRegistration registration : blocksDownloadedEventListeners) { + registration.executor.execute(() -> registration.listener.onBlocksDownloaded(BlocknetPeer.this, block, filteredBlock, blocksLeft)); + } + } + + @Override + public int receiveBytes(ByteBuffer buff) { + if (buff.position() != 0 || buff.capacity() < BlocknetPacketHeader.HEADER_LENGTH + 4) { + throw new IllegalArgumentException("Buffer position is nonzero or bad header."); + } + + try { + boolean firstMessage = true; + + while (true) { + if (largeReadBuffer.get() != null) { + if (!firstMessage) { + throw new IllegalStateException("Bad firstMessage state."); + } + + int bytesToGet = Math.min(buff.remaining(), largeReadBuffer.get().length - largeReadBufferPos.get()); + byte[] buf = largeReadBuffer.get(); + buff.get(buf, largeReadBufferPos.get(), bytesToGet); + + largeReadBuffer.set(buf); + + largeReadBufferPos.set(largeReadBufferPos.get() + bytesToGet); + + if (largeReadBufferPos.get() == largeReadBuffer.get().length) { + processMessage(serializer.deserializePayload(header.get(), ByteBuffer.wrap(largeReadBuffer.get()))); + + largeReadBuffer.set(null); + header = null; + firstMessage = false; + } else { + return buff.position(); + } + } + + Message message; + int preSerializePos = buff.position(); + try { + message = serializer.deserialize(buff); + } catch (BufferUnderflowException e) { + if (firstMessage && buff.limit() == buff.capacity()) { + buff.position(0); + + try { + serializer.seekPastMagicBytes(buff); + header.set(serializer.deserializeHeader(buff)); + + largeReadBufferPos.set(buff.remaining()); + byte[] buf = new byte[header.get().getLength()]; + buff.get(buf, 0, largeReadBufferPos.get()); + largeReadBuffer.set(buf); + } catch (BufferUnderflowException e1) { + throw new ProtocolException("No magic/header after reading " + buff.capacity() + " bytes."); + } + } else { + buff.position(preSerializePos); + } + + return buff.position(); + } + + processMessage(message); + firstMessage = false; + } + } catch (Exception e) { + LOGGER.warning("[blocknet] Error closing peer connection" + e.getMessage()); + return -1; + } + } + + public void parsePlugins(JSONObject pluginsList) { + for (String plugin : pluginsList.keySet()) { + String rawPluginConfig = pluginsList.getString(plugin); + + XRouterConfiguration.XRouterPluginConfiguration pluginConfig = new XRouterConfiguration.XRouterPluginConfiguration(plugin, rawPluginConfig); + pluginConfig.parsePluginConfig(); + getPluginConfigurations().add(pluginConfig); + } + } + + public XRouterConfiguration.XRouterPluginConfiguration getPluginConfig(String pluginName) { + for (XRouterConfiguration.XRouterPluginConfiguration pluginConfig : pluginConfigurations) { + if (pluginConfig.getPluginName().equals(pluginName)) { + return pluginConfig; + } + } + + return null; + } + + public CopyOnWriteArrayList getMessagesPendingReply() { + return messagesPendingReply; + } + + public CopyOnWriteArrayList> getInitialMessagesSentListeners() { + return initialMessagesSentListeners; + } + + public CopyOnWriteArrayList> getXRouterMessageListeners() { + return xRouterMessageListeners; + } + + public void addInitialMessagesSentListener(XRouterInitialMessagesSentListener listener) { + initialMessagesSentListeners.add(new ListenerRegistration<>(listener, Threading.SAME_THREAD)); + } + + public void removeInitialMessagesSentListener(XRouterInitialMessagesSentListener listener) { + ListenerRegistration.removeFromList(listener, initialMessagesSentListeners); + } + + public BlocknetSeed getBlocknetSeed() { + return blocknetSeed; + } + + public AtomicBoolean getHaveConfig() { + return haveConfig; + } + + public XRouterConfiguration getxRouterConfiguration() { + return xRouterConfiguration; + } + + public ArrayList getPluginConfigurations() { + return pluginConfigurations; + } + + public boolean isActivePeer() { + return activePeer; + } + + public boolean pastConnectionSuccess() { + return pastConnectionSuccess; + } + + public boolean hasRequiredPlugins() { + return hasRequiredPlugins; + } + + public void setPastConnectionSuccess(boolean pastConnectionSuccess) { + this.pastConnectionSuccess = pastConnectionSuccess; + } + + public void setHaveConfig(boolean hasConfig) { + this.haveConfig.set(hasConfig); + } + + public void setxRouterConfiguration(XRouterConfiguration xRouterConfiguration) { + this.xRouterConfiguration = xRouterConfiguration; + } + + public void setHasRequiredPlugins(boolean hasRequiredPlugins) { + this.hasRequiredPlugins = hasRequiredPlugins; + } +} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPeerGroup.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPeerGroup.java similarity index 77% rename from src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPeerGroup.java rename to src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPeerGroup.java index 80cbd78..3f8a214 100644 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetPeerGroup.java +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetPeerGroup.java @@ -1,22 +1,23 @@ -package io.cloudchains.app.net.protocols.blocknet; +package io.xlite.daemon.app.net.protocols.blocknet; import com.google.common.base.Preconditions; import com.google.common.base.Throwables; import com.google.common.util.concurrent.*; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.net.CoinTickerUtils; -import io.cloudchains.app.net.protocols.blocknet.listeners.BlocknetOnXRouterMessageReceivedListener; -import io.cloudchains.app.net.protocols.blocknet.listeners.BlocknetPeerConnectedEventListener; -import io.cloudchains.app.net.protocols.blocknet.listeners.BlocknetPeerDisconnectedEventListener; -import io.cloudchains.app.net.protocols.blocknet.messagequeue.MessageSource; -import io.cloudchains.app.net.protocols.blocknet.messagequeue.QueueItem; -import io.cloudchains.app.net.xrouter.XRouterCommandUtils; -import io.cloudchains.app.net.xrouter.XRouterInitialMessagesSentListener; -import io.cloudchains.app.net.xrouter.XRouterMessage; -import io.cloudchains.app.util.UTXO; -import io.cloudchains.app.util.XRouterConfiguration; -import io.cloudchains.app.util.background.BackgroundTimerThread; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.net.protocols.blocknet.listeners.BlocknetOnXRouterMessageReceivedListener; +import io.xlite.daemon.app.net.protocols.blocknet.listeners.BlocknetPeerConnectedEventListener; +import io.xlite.daemon.app.net.protocols.blocknet.listeners.BlocknetPeerDisconnectedEventListener; +import io.xlite.daemon.app.net.protocols.blocknet.messagequeue.MessageSource; +import io.xlite.daemon.app.net.protocols.blocknet.messagequeue.QueueItem; +import io.xlite.daemon.app.net.xrouter.XRouterCommandUtils; +import io.xlite.daemon.app.net.xrouter.XRouterInitialMessagesSentListener; +import io.xlite.daemon.app.net.xrouter.XRouterMessage; +import io.xlite.daemon.app.util.Sats; +import io.xlite.daemon.app.util.UTXO; +import io.xlite.daemon.app.util.XRouterConfiguration; +import io.xlite.daemon.app.util.background.BackgroundTimerThread; import org.bitcoinj.core.BlockChain; import org.bitcoinj.core.Message; import org.bitcoinj.core.PeerAddress; @@ -36,7 +37,6 @@ import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; -import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; @@ -107,7 +107,7 @@ protected ListeningScheduledExecutorService createPrivateExecutor() { private BlocknetPeer createPeer(BlocknetParameters blocknetNetworkParameters, BlockChain chain, BlocknetSeed blocknetSeed) { PeerAddress peerAddress; try { - peerAddress = new PeerAddress(InetAddress.getByName(blocknetSeed.getAddress()), blocknetSeed.getPort(), 0); + peerAddress = new PeerAddress(blocknetNetworkParameters, InetAddress.getByName(blocknetSeed.getAddress()), blocknetSeed.getPort()); } catch (UnknownHostException e) { return null; } @@ -115,7 +115,8 @@ private BlocknetPeer createPeer(BlocknetParameters blocknetNetworkParameters, Bl return new BlocknetPeer(blocknetNetworkParameters, chain, peerAddress, - blocknetSeed) {}; + blocknetSeed) { + }; } @GuardedBy("lock") @@ -131,14 +132,14 @@ private void connectTo(InetSocketAddress inetSocketAddress, BlocknetPeer blockne blocknetPeer.addPeerDisconnectedEventListener(startupListener); pendingPeers.add(blocknetPeer); - LOGGER.log(Level.FINER, "[blocknet-peer-group] Attempting to connect to: " + inetSocketAddress.getHostName()); + LOGGER.finer("[blocknet-peer-group] Attempting to connect to: " + inetSocketAddress.getHostName()); try { ListenableFuture future = clientManager.openConnection(inetSocketAddress, blocknetPeer); if (future.isDone()) Uninterruptibles.getUninterruptibly(future); } catch (ExecutionException e) { - e.printStackTrace(); + LOGGER.warning("[blocknet] Error connecting to peer" + e.getMessage()); Throwable cause = Throwables.getRootCause(e); handlePeerDeath(blocknetPeer, cause); } @@ -153,7 +154,7 @@ private void startConnections() { if (blocknetPeer == null) continue; - LOGGER.log(Level.FINER, "[blocknet-peer-group] Connecting to " + blocknetPeer.getBlocknetSeed().getAddress() + ":" + blocknetPeer.getBlocknetSeed().getPort()); + LOGGER.finer("[blocknet-peer-group] Connecting to " + blocknetPeer.getBlocknetSeed().getAddress() + ":" + blocknetPeer.getBlocknetSeed().getPort()); connectTo(new InetSocketAddress(blocknetPeer.getBlocknetSeed().getAddress(), blocknetPeer.getBlocknetSeed().getPort()), blocknetPeer); } } finally { @@ -161,12 +162,21 @@ private void startConnections() { } } + // Dormant path: start() is unreachable today (its only call site is + // commented out in CoinInstance). If ever revived, this must NOT + // submit a second BackgroundTimerThread — ConsoleMenu already runs + // the sole timer anchor; a duplicate would double-keepalive and + // double-schedule log rotation. Daemon factory keeps it from becoming + // an unplanned liveness anchor. private void startBackgroundThreads() { - threadPool = Executors.newSingleThreadExecutor(); - threadPool.submit(new BackgroundTimerThread()); + threadPool = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "blocknet-peer-group-bg"); + t.setDaemon(true); + return t; + }); } - private ListenableFuture startAsync() { + private ListenableFuture startAsync() { executorStartupLatch.countDown(); return executor.submit(() -> { @@ -181,8 +191,9 @@ private ListenableFuture startAsync() { // scheduleMessageQueueRuns(); } catch (Throwable e) { - e.printStackTrace(); + LOGGER.warning("[blocknet] Error starting connections" + e.getMessage()); } + return null; }); } @@ -194,16 +205,17 @@ public void stop() { try { clientManager.stopAsync(); clientManager.awaitTerminated(); - threadPool.shutdownNow(); + if (threadPool != null) { + threadPool.shutdownNow(); + } } catch (Exception e) { - LOGGER.log(Level.FINER, "[coin] ERROR: Error while deinitializing keep alive or balance update thread!"); - e.printStackTrace(); + LOGGER.warning("[blocknet] Error stopping peer group" + e.getMessage()); } } public void sendMessage(BlocknetPeer blocknetPeer, Message message) { if (blocknetPeer == null) { - LOGGER.log(Level.FINER, "[blocknet-peer-group] BlocknetPeer is null!"); + LOGGER.finer("[blocknet-peer-group] BlocknetPeer is null!"); return; } @@ -261,7 +273,7 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { if (peer.getHaveConfig().get()) return; - LOGGER.log(Level.FINER, "[blocknet-peer-group] Sending initial XRouter messages!"); + LOGGER.finer("[blocknet-peer-group] Sending initial XRouter messages!"); CoinInstance activeBlocknetNetwork = blocknetInstance; @@ -297,14 +309,13 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { } if (peer.getPluginConfig("xrmgetutxos") == null) { - LOGGER.log(Level.FINER, "[xrouter] ERROR: Node missing required configuration... Falling back to HTTP if no available nodes. "); + LOGGER.finer("[xrouter] ERROR: Node missing required configuration... Falling back to HTTP if no available nodes. "); peer.setHasRequiredPlugins(false); // peer.close(); } } catch (Exception e) { - LOGGER.log(Level.FINER, "[xrouter] ERROR: Error while parsing XRouter config/plugin list!"); - e.printStackTrace(); + LOGGER.warning("[blocknet] Error processing XRouter config/plugin list" + e.getMessage()); } if (!peer.getHaveConfig().get()) { @@ -325,10 +336,10 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { int blockCount = Integer.parseInt(reply); activeBlocknetNetwork.addBlockCount(CoinTickerUtils.stringToTicker(originalTicker), blockCount); - LOGGER.log(Level.FINER, "Blocks for currency " + originalTicker + ": " + reply); + LOGGER.finer("Blocks for currency " + originalTicker + ": " + reply); } catch (Exception e) { - LOGGER.log(Level.FINER, "[xrouter] ERROR: Error while parsing XRouter reply to xrGetBlockCount! Dumping reply and stack trace."); - LOGGER.log(Level.FINER, reply); + LOGGER.finer("[xrouter] ERROR: Error while parsing XRouter reply to xrGetBlockCount! Dumping reply and stack trace."); + LOGGER.finer(reply); } break; } @@ -339,8 +350,8 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { switch (originalCustomCmd) { case "xrmgetutxos": { if (replyJson.has("error")) { - LOGGER.log(Level.FINER, "[utxo-parser] ERROR: Error while retrieving UTXOs!"); - LOGGER.log(Level.FINER, replyJson.getString("error")); + LOGGER.finer("[utxo-parser] ERROR: Error while retrieving UTXOs!"); + LOGGER.finer(replyJson.getString("error")); break; } @@ -349,7 +360,7 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { ArrayList originalList = (ArrayList) original.getParsedData().get("params"); String originalTicker = (String) originalList.get(0); - LOGGER.log(Level.FINER, originalTicker); + LOGGER.finer(originalTicker); CoinTicker coinTicker = CoinTickerUtils.stringToTicker(originalTicker.toUpperCase()); CoinInstance inst = CoinInstance.getInstance(coinTicker); @@ -358,13 +369,13 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { for (int i = 0; i < utxosJson.length(); i++) { JSONObject utxoJson = utxosJson.getJSONObject(i); - LOGGER.log(Level.FINER, "[utxo-parser] UTXO " + i + ": " + utxoJson.toString()); + LOGGER.finer("[utxo-parser] UTXO " + i + ": " + utxoJson.toString()); String addressB58 = utxoJson.getString("address"); String txid = utxoJson.getString("txhash"); int vout = utxoJson.getInt("vout"); int height = utxoJson.getInt("block_number"); - long value = (long) Math.floor(utxoJson.getDouble("value") * 100000000.0); + long value = Sats.fromWholeCoins(utxoJson.getDouble("value")); UTXO utxo = new UTXO(coinTicker, addressB58, txid, vout, height, value); utxoList.add(utxo); @@ -374,7 +385,7 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { break; } case "xrmgetbalance": { - LOGGER.log(Level.FINER, "[xrouter] ERROR: xrmgetbalance is not implemented yet!"); + LOGGER.finer("[xrouter] ERROR: xrmgetbalance is not implemented yet!"); break; } case "xrmgetrawtransaction": @@ -382,19 +393,19 @@ private void sendInitialXRouterMessages(BlocknetPeer peer) { break; } default: { - LOGGER.log(Level.FINER, "[xrouter] ERROR: Received reply for command we don't recognize! Original custom command: " + originalCustomCmd + ". Dumping reply."); - LOGGER.log(Level.FINER, reply); + LOGGER.finer("[xrouter] ERROR: Received reply for command we don't recognize! Original custom command: " + originalCustomCmd + ". Dumping reply."); + LOGGER.finer(reply); break; } } } catch (Exception e) { - LOGGER.log(Level.FINER, "[xrouter] ERROR: Error while parsing XRouter reply to xrService! Original custom command: " + originalCustomCmd + ". Dumping reply and stack trace."); - LOGGER.log(Level.FINER, reply); + LOGGER.finer("[xrouter] ERROR: Error while parsing XRouter reply to xrService! Original custom command: " + originalCustomCmd + ". Dumping reply and stack trace."); + LOGGER.finer(reply); } break; } default: { - LOGGER.log(Level.FINER, "[xrouter] WARNING: Core received reply to unexpected packet type. This is probably not a bug. Original command: " + XRouterCommandUtils.commandIdToString(originalCmd)); + LOGGER.finer("[xrouter] WARNING: Core received reply to unexpected packet type. This is probably not a bug. Original command: " + XRouterCommandUtils.commandIdToString(originalCmd)); break; } } @@ -424,17 +435,17 @@ private void handleNewPeer(final BlocknetPeer peer, int peerCount) { peers.add(peer); peer.addPreMessageReceivedEventListener((thisPeer, message) -> { - LOGGER.log(Level.FINER, "[blocknet-peer] Message received from peer: " + peer.getAddress()); + LOGGER.finer("[blocknet-peer] Message received from peer: " + peer.getAddress()); if (message instanceof XRouterMessage) { - LOGGER.log(Level.FINER, "[blocknet-peer] XRouter message received."); + LOGGER.finer("[blocknet-peer] XRouter message received."); } return message; }); - LOGGER.log(Level.FINER, "[peer] Peer " + peer.getAddress().toString() + " connected, version handshake done. peerCount = " + peerCount); + LOGGER.finer("[peer] Peer " + peer.getAddress().toString() + " connected, version handshake done. peerCount = " + peerCount); if (!blocknetInstance.hasXRouter()) { - LOGGER.log(Level.FINER, "[xrouter] WARNING: This network (" + blocknetInstance.getTicker().toString() + ") does not support XRouter. Will attempt to send XRouter messages over active Blocknet network."); + LOGGER.finer("[xrouter] WARNING: This network (" + blocknetInstance.getTicker().toString() + ") does not support XRouter. Will attempt to send XRouter messages over active Blocknet network."); return; } @@ -451,7 +462,7 @@ public void initialMessagesSent(CoinInstance instance) { if (!peer.getHaveConfig().get()) { sendInitialXRouterMessages(peer); - LOGGER.log(Level.FINER, "[blocknet-peer-group] Sent initial messages to peer: " + peer.getAddress()); + LOGGER.finer("[blocknet-peer-group] Sent initial messages to peer: " + peer.getAddress()); } peer.getBlocknetSeed().resetCounters(); @@ -468,7 +479,7 @@ private void handlePeerDeath(final BlocknetPeer peer, @Nullable Throwable except if (peer.getHaveConfig().get() || peer.pastConnectionSuccess()) { pendingPeers.add(peer); - LOGGER.log(Level.FINER, "[testing ] Peer saved."); + LOGGER.finer("[testing ] Peer saved."); } peer.setHaveConfig(false); @@ -477,7 +488,7 @@ private void handlePeerDeath(final BlocknetPeer peer, @Nullable Throwable except blocknetSeed.incrementFailCounter(); blocknetSeed.setActivePeer(false); - LOGGER.log(Level.FINER, "[blocknet-peer-group] Peer died: " + blocknetSeed.getAddress()); + LOGGER.finer("[blocknet-peer-group] Peer died: " + blocknetSeed.getAddress()); setActiveConnectionCount(peers.size()); } finally { @@ -489,7 +500,7 @@ private void handlePeerDeath(final BlocknetPeer peer, @Nullable Throwable except private Runnable attemptReconnects(boolean forceReconnect) { return () -> { - LOGGER.log(Level.FINER, "[blocknet-peer-group] Checking if we can reconnect to any disconnected peers..."); + LOGGER.finer("[blocknet-peer-group] Checking if we can reconnect to any disconnected peers..."); try { for (BlocknetSeed blocknetSeed : blocknetSeeds) { @@ -504,7 +515,7 @@ private Runnable attemptReconnects(boolean forceReconnect) { && blocknetSeed.getLastFailTimeDiff() >= reconnectTime; if (attemptReconnect || forceReconnect) { - LOGGER.log(Level.FINER, "[blocknet-peer-group] Reconnecting to peer: " + blocknetSeed.getAddress()); + LOGGER.finer("[blocknet-peer-group] Reconnecting to peer: " + blocknetSeed.getAddress()); BlocknetPeer blocknetPeer = createPeer(blocknetNetworkParameters, blockChain, blocknetSeed); if (blocknetPeer == null) @@ -514,7 +525,7 @@ private Runnable attemptReconnects(boolean forceReconnect) { } } } catch (Exception e) { - e.printStackTrace(); + LOGGER.warning("[blocknet] Error handling peer group event" + e.getMessage()); } }; } @@ -524,10 +535,12 @@ private void attemptReconnect(BlocknetPeer blocknetPeer) { BlocknetSeed blocknetSeed = blocknetPeer.getBlocknetSeed(); connectTo(new InetSocketAddress(blocknetSeed.getAddress(), blocknetSeed.getPort()), blocknetPeer); } catch (Exception e) { - e.printStackTrace(); + LOGGER.warning("[blocknet] Error during peer group shutdown" + e.getMessage()); } } + // --- XRouter connector message queue (latent feature) --- + public void addToMessageQueue(QueueItem queueItem) { messageQueue.add(queueItem); } @@ -536,7 +549,7 @@ private Runnable processQueue() { return () -> { CoinInstance coinInstance = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()); - LOGGER.log(Level.FINER, "[blocknet-peer-group] processing message queue"); + LOGGER.finer("[blocknet-peer-group] processing message queue"); if (messageQueue.size() == 0) return; @@ -576,7 +589,7 @@ private Runnable processQueue() { if (queueItem.getCommmand().equals("xrSendTransaction") && queueItem.getMessageSource() == MessageSource.SOURCE_GUI) { BlocknetPeer finalBlocknetPeer = blocknetPeer; - LOGGER.log(Level.FINER, "Transaction successful!"); + LOGGER.finer("Transaction successful!"); } else if (queueItem.getNewPeer() != null) { for (ListenerRegistration listener : queueItem.getOriginalPeer().getXRouterMessageListeners()) { queueItem.getNewPeer().addXRouterMessageReceivedEventListener(listener.listener); @@ -591,18 +604,18 @@ private Runnable processQueue() { private boolean waitForConnection(BlocknetPeer blocknetPeer, int maxWaitSeconds) { long startTime = System.currentTimeMillis(); - while((System.currentTimeMillis() - startTime) < (maxWaitSeconds * 1000)) { + while ((System.currentTimeMillis() - startTime) < (maxWaitSeconds * 1000)) { BlocknetPeer filteredPeer = getConnectedPeers().stream().filter( e -> (e.getHaveConfig().get() && e.getAddress().getAddr() == blocknetPeer.getAddress().getAddr()) ).findFirst().orElse(null); - if (filteredPeer != null) + if (filteredPeer != null) { return true; - else { + } else { try { Thread.sleep(100); } catch (InterruptedException e) { - e.printStackTrace(); + LOGGER.warning("[blocknet] Error waiting for connection" + e.getMessage()); } } } @@ -614,6 +627,8 @@ private void scheduleMessageQueueRuns() { executor.scheduleWithFixedDelay(processQueue(), 0, 1, TimeUnit.SECONDS); } + // --- end XRouter connector --- + private void scheduleReconnects() { executor.scheduleWithFixedDelay(attemptReconnects(false), 0, 60, TimeUnit.SECONDS); } diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetSeed.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetSeed.java similarity index 85% rename from src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetSeed.java rename to src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetSeed.java index 4a58859..f3f1902 100644 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/BlocknetSeed.java +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetSeed.java @@ -1,4 +1,4 @@ -package io.cloudchains.app.net.protocols.blocknet; +package io.xlite.daemon.app.net.protocols.blocknet; import java.util.concurrent.TimeUnit; @@ -6,10 +6,10 @@ public class BlocknetSeed { private String address; private Integer port; - private int failCount; - private long lastFailTime; + private volatile int failCount; + private volatile long lastFailTime; - private boolean isActivePeer; + private volatile boolean isActivePeer; BlocknetSeed(String address, int port) { this.address = address; diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetSerializer.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetSerializer.java new file mode 100644 index 0000000..b715224 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetSerializer.java @@ -0,0 +1,218 @@ +package io.xlite.daemon.app.net.protocols.blocknet; + +import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.net.protocols.blocknet.messages.VersionMessageImpl; +import io.xlite.daemon.app.net.xrouter.XRouterMessage; +import org.bitcoinj.core.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class BlocknetSerializer extends BitcoinSerializer { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private BlocknetParameters params; + private boolean parseRetain; + + private static final HashMap, String> messageNames = new HashMap<>(); + + static { + messageNames.put(VersionMessage.class, "version"); + messageNames.put(VersionMessageImpl.class, "version"); + messageNames.put(InventoryMessage.class, "inv"); + messageNames.put(Block.class, "block"); + messageNames.put(GetDataMessage.class, "getdata"); + messageNames.put(Transaction.class, "tx"); + messageNames.put(AddressMessage.class, "addr"); + messageNames.put(Ping.class, "ping"); + messageNames.put(Pong.class, "pong"); + messageNames.put(VersionAck.class, "verack"); + messageNames.put(GetBlocksMessage.class, "getblocks"); + messageNames.put(GetHeadersMessage.class, "getheaders"); + messageNames.put(GetAddrMessage.class, "getaddr"); + messageNames.put(HeadersMessage.class, "headers"); + messageNames.put(BloomFilter.class, "filterload"); + messageNames.put(FilteredBlock.class, "merkleblock"); + messageNames.put(NotFoundMessage.class, "notfound"); + messageNames.put(MemoryPoolMessage.class, "mempool"); + messageNames.put(RejectMessage.class, "reject"); + messageNames.put(GetUTXOsMessage.class, "getutxos"); + messageNames.put(UTXOsMessage.class, "utxos"); + } + + public BlocknetSerializer(NetworkParameters params, boolean parseRetain) { + super(params, parseRetain); + + if (!(params instanceof BlocknetParameters)) { + throw new IllegalArgumentException("Invalid network parameters for Blocknet."); + } + + this.params = (BlocknetParameters) params; + this.parseRetain = parseRetain; + } + + @Override + public BlocknetPacketHeader deserializeHeader(ByteBuffer in) throws ProtocolException { + //in.position(0); + //seekPastMagicBytes(in); + return new BlocknetPacketHeader(in); + } + + @Override + public Message deserializePayload(BitcoinSerializer.BitcoinPacketHeader header, ByteBuffer in) throws ProtocolException, BufferUnderflowException { + //in.position(0); + //seekPastMagicBytes(in); + BlocknetPacketHeader blocknetPacketHeader = (BlocknetPacketHeader) header; + + byte[] payloadBytes = new byte[blocknetPacketHeader.getLength()]; + in.get(payloadBytes, 0, payloadBytes.length); + + if (!BlocknetUtils.verifyChecksum(blocknetPacketHeader, payloadBytes)) { + throw new ProtocolException("Checksum failed to verify."); + } + + switch (blocknetPacketHeader.getCommand().toLowerCase()) { + case "xrouter": + LOGGER.finer("[blocknet-serializer] Received XRouter packet, at position: " + in.position()); + return new XRouterMessage(params, payloadBytes); + case "version": +// LOGGER.finer("[blocknet-serializer] Version message received"); + return new VersionMessage(params, payloadBytes); + case "inv": +// LOGGER.finer("[blocknet-serializer] Warning: Inventory messages are ignored"); + return null; + case "block": + return new Block(params, payloadBytes, 0, this, blocknetPacketHeader.getLength()); + case "merkleblock": + return new FilteredBlock(params, payloadBytes); + case "getdata": + return new GetDataMessage(params, payloadBytes, this, blocknetPacketHeader.getLength()); + case "getblocks": + return new GetBlocksMessage(params, payloadBytes); + case "getheaders": + return new GetHeadersMessage(params, payloadBytes); + case "tx": + return new Transaction(params, payloadBytes, 0, null, this, blocknetPacketHeader.getLength(), null); + case "addr": + return makeAddressMessage(payloadBytes, blocknetPacketHeader.getLength()); + case "alert": + return new AlertMessage(params, payloadBytes); + case "ping": + return new Ping(params, payloadBytes); + case "pong": + return new Pong(params, payloadBytes); + case "verack": + return new VersionAck(params, payloadBytes); + case "headers": + return new HeadersMessage(params, payloadBytes); + case "filterload": + return new BloomFilter(params, payloadBytes); + case "notfound": + return new NotFoundMessage(params, payloadBytes); + case "mempool": + return new MemoryPoolMessage(); + case "reject": + return new RejectMessage(params, payloadBytes); + case "utxos": + return new UTXOsMessage(params, payloadBytes); + case "getutxos": + return new GetUTXOsMessage(params, payloadBytes); + case "getsporks": + case "ssc": + case "mnget": + case "xbridge": +// LOGGER.finer("[blocknet-serializer] Warning: This serializer does not support deserializing xbridge/ssc/mnget/getsporks packets yet."); + return null; + case "dseg": +// LOGGER.finer("[blocknet-serializer] Warning: This serializer does not support deserializing dseg packets yet."); + return null; + default: + LOGGER.finer("[blocknet-serializer] Warning: This serializer does not support deserializing " + blocknetPacketHeader.getCommand() + " packets (yet)."); + return new UnknownMessage(params, blocknetPacketHeader.getCommand(), payloadBytes); + } + } + + @Override + public Message deserialize(ByteBuffer in) throws ProtocolException { + //in.position(0); + seekPastMagicBytes(in); + BlocknetPacketHeader header = new BlocknetPacketHeader(in); + + return deserializePayload(header, in); + } + + @Override + public boolean isParseRetainMode() { + return parseRetain; + } + + @Override + public AddressMessage makeAddressMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { + return null; + } + + @Override + public Message makeAlertMessage(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { + return new AlertMessage(params, payloadBytes); + } + + @Override + public Block makeBlock(byte[] payloadBytes, int offset, int length) throws ProtocolException, UnsupportedOperationException { + return new Block(params, payloadBytes, offset, this, length); + } + + @Override + public Message makeBloomFilter(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { + return new BloomFilter(params, payloadBytes); + } + + @Override + public FilteredBlock makeFilteredBlock(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { + return new FilteredBlock(params, payloadBytes); + } + + @Override + public InventoryMessage makeInventoryMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { + return new InventoryMessage(params, payloadBytes, this, length); + } + + @Override + public Transaction makeTransaction(byte[] payloadBytes, int offset, int length, byte[] hash) throws ProtocolException, UnsupportedOperationException { + return new Transaction(params, payloadBytes, offset, null, this, length, hash); + } + + @Override + public void seekPastMagicBytes(ByteBuffer in) throws BufferUnderflowException { + BlocknetUtils.seekPastMagicBytes(in, params); + } + + @Override + public void serialize(String name, byte[] message, OutputStream out) throws IOException, UnsupportedOperationException { + byte[] header = BlocknetUtils.getHeader(name, (int) params.getPacketMagic(), message); + + out.write(header); + out.write(message); + + LOGGER.finer("[blocknet-serializer] Serialized " + name + " message. Bytes: " + new String(Hex.encode(header)) + new String(Hex.encode(message))); + } + + @Override + public void serialize(Message message, OutputStream out) throws IOException { + if (message instanceof XRouterMessage) { + params.getXRouterMessageSerializer(parseRetain).serialize(message, out); + } else { + String name = messageNames.get(message.getClass()); + if (name == null) { + LOGGER.finer("[blocknet-serializer] ERROR: BlocknetSerializer cannot serialize " + message.getClass().getSimpleName() + " (yet)!"); + return; + } + serialize(name, message.bitcoinSerialize(), out); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetUtils.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetUtils.java new file mode 100644 index 0000000..f422bfd --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/BlocknetUtils.java @@ -0,0 +1,54 @@ +package io.xlite.daemon.app.net.protocols.blocknet; + +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.core.Utils; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +public class BlocknetUtils { + + public static void seekPastMagicBytes(ByteBuffer in, BlocknetParameters params) { + int magicCursor = 3; + while (true) { + byte b = in.get(); + + byte expectedByte = (byte) (0xFF & params.getPacketMagic() >>> (magicCursor * 8)); + if (b == expectedByte) { + magicCursor--; + if (magicCursor < 0) { + return; + } + } else { + magicCursor = 3; + } + } + } + + public static boolean verifyChecksum(BlocknetPacketHeader header, byte[] payloadBytes) { + byte[] hash = Sha256Hash.hashTwice(payloadBytes); + byte[] headerChecksum = new byte[4]; + byte[] checksum = new byte[4]; + System.arraycopy(header.getChecksum(), 0, headerChecksum, 0, 4); + System.arraycopy(hash, 0, checksum, 0, 4); + + return Arrays.equals(headerChecksum, checksum); + } + + public static byte[] getHeader(String name, int magic, byte[] payloadBytes) { + byte[] header = new byte[BlocknetPacketHeader.HEADER_LENGTH + 4]; + + Utils.uint32ToByteArrayBE(magic, header, 0); + + for (int i = 0; i < name.length() && i < 12; i++) { + header[4 + i] = (byte) (name.codePointAt(i) & 0xFF); + } + + Utils.uint32ToByteArrayLE(payloadBytes.length, header, 4 + 12); + byte[] hash = Sha256Hash.hashTwice(payloadBytes); + System.arraycopy(hash, 0, header, 4 + 12 + 4, 4); + + return header; + } + +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetOnBlocksDownloadedEventListener.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetOnBlocksDownloadedEventListener.java new file mode 100644 index 0000000..445a9a6 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetOnBlocksDownloadedEventListener.java @@ -0,0 +1,11 @@ +package io.xlite.daemon.app.net.protocols.blocknet.listeners; + +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import org.bitcoinj.core.Block; +import org.bitcoinj.core.FilteredBlock; + +public interface BlocknetOnBlocksDownloadedEventListener { + + void onBlocksDownloaded(BlocknetPeer peer, Block block, FilteredBlock filteredBlock, int blocksLeft); + +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetOnXRouterMessageReceivedListener.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetOnXRouterMessageReceivedListener.java new file mode 100644 index 0000000..36a9145 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetOnXRouterMessageReceivedListener.java @@ -0,0 +1,9 @@ +package io.xlite.daemon.app.net.protocols.blocknet.listeners; + +import io.xlite.daemon.app.net.xrouter.XRouterMessage; + +public interface BlocknetOnXRouterMessageReceivedListener { + + void onXRouterMessageReceived(XRouterMessage message, XRouterMessage original); + +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPeerConnectedEventListener.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPeerConnectedEventListener.java new file mode 100644 index 0000000..74aec26 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPeerConnectedEventListener.java @@ -0,0 +1,8 @@ +package io.xlite.daemon.app.net.protocols.blocknet.listeners; + +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; + +public interface BlocknetPeerConnectedEventListener { + + void onPeerConnected(BlocknetPeer peer, int peerCount); +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPeerDisconnectedEventListener.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPeerDisconnectedEventListener.java new file mode 100644 index 0000000..ab6454c --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPeerDisconnectedEventListener.java @@ -0,0 +1,9 @@ +package io.xlite.daemon.app.net.protocols.blocknet.listeners; + +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; + +public interface BlocknetPeerDisconnectedEventListener { + + void onPeerDisconnected(BlocknetPeer peer, int peerCount); + +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPreMessageReceivedEventListener.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPreMessageReceivedEventListener.java new file mode 100644 index 0000000..ffa400d --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/listeners/BlocknetPreMessageReceivedEventListener.java @@ -0,0 +1,10 @@ +package io.xlite.daemon.app.net.protocols.blocknet.listeners; + +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import org.bitcoinj.core.Message; + +public interface BlocknetPreMessageReceivedEventListener { + + Message onPreMessageReceived(BlocknetPeer peer, Message message); + +} diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/messagequeue/MessageSource.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messagequeue/MessageSource.java similarity index 50% rename from src/main/java/io/cloudchains/app/net/protocols/blocknet/messagequeue/MessageSource.java rename to src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messagequeue/MessageSource.java index df4bb4f..1461f93 100644 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/messagequeue/MessageSource.java +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messagequeue/MessageSource.java @@ -1,4 +1,4 @@ -package io.cloudchains.app.net.protocols.blocknet.messagequeue; +package io.xlite.daemon.app.net.protocols.blocknet.messagequeue; public enum MessageSource { SOURCE_GUI, diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/messagequeue/QueueItem.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messagequeue/QueueItem.java similarity index 93% rename from src/main/java/io/cloudchains/app/net/protocols/blocknet/messagequeue/QueueItem.java rename to src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messagequeue/QueueItem.java index 16a55c5..76807c2 100644 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/messagequeue/QueueItem.java +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messagequeue/QueueItem.java @@ -1,6 +1,6 @@ -package io.cloudchains.app.net.protocols.blocknet.messagequeue; +package io.xlite.daemon.app.net.protocols.blocknet.messagequeue; -import io.cloudchains.app.net.protocols.blocknet.BlocknetPeer; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; import java.util.HashMap; diff --git a/src/main/java/io/cloudchains/app/net/protocols/blocknet/messages/VersionMessageImpl.java b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messages/VersionMessageImpl.java similarity index 81% rename from src/main/java/io/cloudchains/app/net/protocols/blocknet/messages/VersionMessageImpl.java rename to src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messages/VersionMessageImpl.java index 79f576f..abeb527 100644 --- a/src/main/java/io/cloudchains/app/net/protocols/blocknet/messages/VersionMessageImpl.java +++ b/src/main/java/io/xlite/daemon/app/net/protocols/blocknet/messages/VersionMessageImpl.java @@ -1,6 +1,6 @@ -package io.cloudchains.app.net.protocols.blocknet.messages; +package io.xlite.daemon.app.net.protocols.blocknet.messages; -import io.cloudchains.app.Version; +import io.xlite.daemon.app.Version; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.ProtocolException; import org.bitcoinj.core.VersionMessage; diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/dashcoin/DashcoinNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/dashcoin/DashcoinNetworkParametersLegacy.java new file mode 100644 index 0000000..687712f --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/dashcoin/DashcoinNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.dashcoin; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class DashcoinNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public DashcoinNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(22000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Coin.valueOf(5460); + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "DASH"); + } + + @Override + public String getUriScheme() { + return "dashcoin:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70210; + } + + @Override + public int getAddressHeader() { + return 76; + } + + @Override + public int getP2SHHeader() { + return 16; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 204; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 210240; + } + + @Override + public int getInterval() { + return 57; + } + + @Override + public String getId() { + return "DASH"; + } + + public long getFeePerByte() { + return 5; + } + + public long getMinTxFee() { + return 2500; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/digibyte/DigibyteNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/digibyte/DigibyteNetworkParametersLegacy.java new file mode 100644 index 0000000..db91e39 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/digibyte/DigibyteNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.digibyte; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class DigibyteNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public DigibyteNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(2000000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Coin.valueOf(1000); + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "DGB"); + } + + @Override + public String getUriScheme() { + return "digibyte:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70002; + } + + @Override + public int getAddressHeader() { + return 30; + } + + @Override + public int getP2SHHeader() { + return 5; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 128; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 100000; + } + + @Override + public int getInterval() { + return 108; + } + + @Override + public String getId() { + return "DGB"; + } + + public long getFeePerByte() { + return 200; + } + + public long getMinTxFee() { + return 100000; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/dogecoin/DogecoinNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/dogecoin/DogecoinNetworkParametersLegacy.java new file mode 100644 index 0000000..e90aed0 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/dogecoin/DogecoinNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.dogecoin; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class DogecoinNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public DogecoinNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(2000000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Transaction.MIN_NONDUST_OUTPUT; + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "DOGE"); + } + + @Override + public String getUriScheme() { + return "dogecoin:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70004; + } + + @Override + public int getAddressHeader() { + return 30; + } + + @Override + public int getP2SHHeader() { + return 22; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 158; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 100000; + } + + @Override + public int getInterval() { + return 108; + } + + @Override + public String getId() { + return "DOGE"; + } + + public long getFeePerByte() { + return 2500; + } + + public long getMinTxFee() { + return 225000; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/litecoin/LitecoinNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/litecoin/LitecoinNetworkParametersLegacy.java new file mode 100644 index 0000000..55fbb45 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/litecoin/LitecoinNetworkParametersLegacy.java @@ -0,0 +1,101 @@ +package io.xlite.daemon.app.net.protocols.litecoin; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class LitecoinNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public LitecoinNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(84000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Coin.valueOf(100000); + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "LTC"); + } + + @Override + public String getUriScheme() { + return "litecoin:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70015; + } + + @Override + public int getAddressHeader() { + return 48; + } + + @Override + public int getP2SHHeader() { + return 50; + } + + public int getP2SHLegacyHeader() { + return 5; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 176; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 840000; + } + + @Override + public int getInterval() { + return 2016; + } + + @Override + public String getId() { + return "LTC"; + } + + public long getFeePerByte() { + return 10; + } + + public long getMinTxFee() { + return 5000; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/pivx/PivxNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/pivx/PivxNetworkParametersLegacy.java new file mode 100644 index 0000000..b861ac3 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/pivx/PivxNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.pivx; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class PivxNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public PivxNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(100000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Transaction.MIN_NONDUST_OUTPUT; + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "PIVX"); + } + + @Override + public String getUriScheme() { + return "pivx:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70007; + } + + @Override + public int getAddressHeader() { + return 30; + } + + @Override + public int getP2SHHeader() { + return 13; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 212; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 210240; + } + + @Override + public int getInterval() { + return 60; + } + + @Override + public String getId() { + return "PIVX"; + } + + public long getFeePerByte() { + return 20; + } + + public long getMinTxFee() { + return 10000; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/pocketcoin/PocketcoinNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/pocketcoin/PocketcoinNetworkParametersLegacy.java new file mode 100644 index 0000000..98d37f9 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/pocketcoin/PocketcoinNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.pocketcoin; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class PocketcoinNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public PocketcoinNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(21000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Transaction.MIN_NONDUST_OUTPUT; + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "PKOIN"); + } + + @Override + public String getUriScheme() { + return "pocketcoin:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70031; + } + + @Override + public int getAddressHeader() { + return 55; + } + + @Override + public int getP2SHHeader() { + return 80; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 33; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 2100000; + } + + @Override + public int getInterval() { + return 60; + } + + @Override + public String getId() { + return "PKOIN"; + } + + public long getFeePerByte() { + return 20; + } + + public long getMinTxFee() { + return 10000; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/ravencoin/RavencoinNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/ravencoin/RavencoinNetworkParametersLegacy.java new file mode 100644 index 0000000..241e581 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/ravencoin/RavencoinNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.ravencoin; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class RavencoinNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public RavencoinNetworkParametersLegacy() { + super(); + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(100000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Transaction.MIN_NONDUST_OUTPUT; + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "RVN"); + } + + @Override + public String getUriScheme() { + return "ravencoin:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70026; + } + + @Override + public int getAddressHeader() { + return 60; + } + + @Override + public int getP2SHHeader() { + return 122; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 128; + } + + + @Override + public int getSubsidyDecreaseBlockCount() { + return 210240; + } + + @Override + public int getInterval() { + return 60; + } + + @Override + public String getId() { + return "RVN"; + } + + public long getFeePerByte() { + return 1000; + } + + public long getMinTxFee() { + return 100000; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/protocols/syscoin/SyscoinNetworkParametersLegacy.java b/src/main/java/io/xlite/daemon/app/net/protocols/syscoin/SyscoinNetworkParametersLegacy.java new file mode 100644 index 0000000..9962b3f --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/protocols/syscoin/SyscoinNetworkParametersLegacy.java @@ -0,0 +1,97 @@ +package io.xlite.daemon.app.net.protocols.syscoin; + +import io.xlite.daemon.app.net.HasFeeParams; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; + +public class SyscoinNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { + + public SyscoinNetworkParametersLegacy() { + super(); + } + + + @Override + public String getPaymentProtocolId() { + return "main"; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } + + @Override + public Coin getMaxMoney() { + return Coin.valueOf(888000000 * Coin.COIN.value); + } + + @Override + public Coin getMinNonDustOutput() { + return Coin.valueOf(5500); + } + + @Override + public MonetaryFormat getMonetaryFormat() { + return new MonetaryFormat().code(0, "SYS"); + } + + @Override + public String getUriScheme() { + return "syscoin:"; + } + + @Override + public boolean hasMaxMoney() { + return true; + } + + @Override + public BitcoinSerializer getSerializer(boolean parseRetain) { + return new BitcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(ProtocolVersion version) { + return 70227; + } + + @Override + public int getAddressHeader() { + return 63; + } + + @Override + public int getP2SHHeader() { + return 5; + } + + @Override + public int getDumpedPrivateKeyHeader() { + return 128; + } + + @Override + public int getSubsidyDecreaseBlockCount() { + return 525600; + } + + @Override + public int getInterval() { + return 2016; + } + + @Override + public String getId() { + return "SYS"; + } + + public long getFeePerByte() { + return 40; + } + + public long getMinTxFee() { + return 20000; + } +} diff --git a/src/main/java/io/cloudchains/app/net/protocols/unobtanium/UnobtaniumNetworkParameters.java b/src/main/java/io/xlite/daemon/app/net/protocols/unobtanium/UnobtaniumNetworkParametersLegacy.java similarity index 65% rename from src/main/java/io/cloudchains/app/net/protocols/unobtanium/UnobtaniumNetworkParameters.java rename to src/main/java/io/xlite/daemon/app/net/protocols/unobtanium/UnobtaniumNetworkParametersLegacy.java index ee98390..e77ad0a 100644 --- a/src/main/java/io/cloudchains/app/net/protocols/unobtanium/UnobtaniumNetworkParameters.java +++ b/src/main/java/io/xlite/daemon/app/net/protocols/unobtanium/UnobtaniumNetworkParametersLegacy.java @@ -1,29 +1,31 @@ -package io.cloudchains.app.net.protocols.unobtanium; +package io.xlite.daemon.app.net.protocols.unobtanium; +import io.xlite.daemon.app.net.HasFeeParams; import org.bitcoinj.core.*; import org.bitcoinj.store.BlockStore; import org.bitcoinj.store.BlockStoreException; import org.bitcoinj.utils.MonetaryFormat; -public class UnobtaniumNetworkParameters extends NetworkParameters { +public class UnobtaniumNetworkParametersLegacy extends NetworkParameters implements HasFeeParams { - public UnobtaniumNetworkParameters() { + public UnobtaniumNetworkParametersLegacy() { super(); } - @Override - public String getPaymentProtocolId() { - return "main"; - } + @Override + public String getPaymentProtocolId() { + return "main"; + } - @Override - public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException {} + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block next, BlockStore blockStore) throws VerificationException, BlockStoreException { + } - @Override - public Coin getMaxMoney() { - return Coin.valueOf(250000 * Coin.COIN.value); - } + @Override + public Coin getMaxMoney() { + return Coin.valueOf(250000 * Coin.COIN.value); + } @Override public Coin getMinNonDustOutput() { @@ -70,20 +72,6 @@ public int getDumpedPrivateKeyHeader() { return 224; } - @Override - public int[] getAcceptableAddressCodes() { - return new int[]{getAddressHeader(), getP2SHHeader()}; - } - - @Override - public int getBip32HeaderPriv() { - return 0x0488ADE4; - } - - @Override - public int getBip32HeaderPub() { - return 0x0488B21E; - } @Override public int getSubsidyDecreaseBlockCount() { @@ -99,4 +87,12 @@ public int getInterval() { public String getId() { return "UNO"; } + + public long getFeePerByte() { + return 3; + } + + public long getMinTxFee() { + return 1000; + } } \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterCommandUtils.java b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterCommandUtils.java new file mode 100644 index 0000000..573100f --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterCommandUtils.java @@ -0,0 +1,43 @@ +package io.xlite.daemon.app.net.xrouter; + +import com.google.common.collect.HashBiMap; + +public class XRouterCommandUtils { + + private static final HashBiMap commands; + + static { + commands = HashBiMap.create(19); + + commands.put("xrInvalid", 0); + commands.put("xrReply", 1); + commands.put("xrGetReply", 2); + commands.put("xrGetConfig", 3); + commands.put("xrConfigReply", 4); + commands.put("xrGetBlockCount", 20); + commands.put("xrGetBlockHash", 21); + commands.put("xrGetBlock", 22); + commands.put("xrGetTransaction", 23); + commands.put("xrSendTransaction", 24); + commands.put("xrGetTxBloomFilter", 40); + commands.put("xrGenerateBloomFilter", 41); + commands.put("xrGetBlocks", 50); + commands.put("xrGetTransactions", 51); + commands.put("xrGetBlockAtTime", 52); + commands.put("xrDecodeRawTransaction", 53); + commands.put("xrGetBalance", 60); + commands.put("xrGetBalanceUpdate", 61); + commands.put("xrService", 1000); + } + + public static int commandStringToInt(String command) { + Integer commandId = commands.get(command); + return commandId == null ? 0 : commandId; + } + + public static String commandIdToString(int commandId) { + String command = commands.inverse().get(commandId); + return command == null ? "xrInvalid" : command; + } + +} diff --git a/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterFeeUtils.java b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterFeeUtils.java new file mode 100644 index 0000000..92dd1c7 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterFeeUtils.java @@ -0,0 +1,116 @@ +package io.xlite.daemon.app.net.xrouter; + +import com.google.common.base.Preconditions; +import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import io.xlite.daemon.app.util.Sats; +import io.xlite.daemon.app.util.XRouterConfiguration; +import io.xlite.daemon.app.wallet.WalletHelper; +import org.bitcoinj.core.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class XRouterFeeUtils { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + public static String getXRouterFeeTx(BlocknetPeer blocknetPeer, String xRouterCommand) { + CoinInstance blocknetCoin = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()); + WalletHelper blocknetWalletHelper = blocknetCoin.getWalletHelper(); + NetworkParameters params = blocknetCoin.getNetworkParameters(); + XRouterConfiguration xRouterConfig = blocknetPeer.getxRouterConfiguration(); + + Preconditions.checkNotNull(xRouterConfig, "XRouter config was not received yet!"); + + HashMap feeMap = xRouterConfig.getFeeMap(); + + if (!feeMap.containsKey(xRouterCommand)) { + LOGGER.finer("[xrouter-fee-utils] WARNING: Invalid/unknown XRouter command supplied to getXRouterFeeTx()! Assuming this command is free."); + LOGGER.finer("[xrouter-fee-utils] Command: " + xRouterCommand); + + return "nohash;nofee"; + } + + double fee = feeMap.get(xRouterCommand); + + Coin xRouterFeeAmt = Coin.valueOf(Sats.fromWholeCoins(fee)); + if (xRouterFeeAmt.value == 0) { + LOGGER.finer("[xrouter-fee-utils] DEBUG: This command is free."); + return "nohash;nofee"; + } + + long feePerByte = WalletHelper.getFeePerByte(blocknetCoin.getNetworkParameters()); + long minTxFee = WalletHelper.getMinTxFee(blocknetCoin.getNetworkParameters()); + long networkFeeSats = Math.max(feePerByte * (192 + 34), minTxFee); + double networkFee = (double) networkFeeSats / Coin.COIN.value; + + double totalSpending = fee + networkFee; + double totalAvailable = blocknetWalletHelper.getSpendBalance(totalSpending); + double changeAmt = totalAvailable - networkFee - fee; + + LegacyAddress xRouterPaymentAddress = LegacyAddress.fromBase58(params, xRouterConfig.getFeeAddress()); + Coin blocknetNetworkFeeAmt = Coin.valueOf(networkFeeSats); + Coin xRouterChangeAmt = Coin.valueOf(Sats.fromWholeCoins(totalAvailable)).minus(blocknetNetworkFeeAmt).minus(xRouterFeeAmt); + + TransactionOutput feeOutput = new TransactionOutput(params, null, xRouterFeeAmt, xRouterPaymentAddress); + + ArrayList outputs = new ArrayList<>(); + outputs.add(feeOutput); + + if (changeAmt > 0.06) { + double halvedAmt = changeAmt / 3; + Coin halvedChangeAmt = Coin.valueOf(Sats.fromWholeCoins(halvedAmt)); + TransactionOutput halvedChangeOutput = new TransactionOutput(params, null, halvedChangeAmt, blocknetWalletHelper.getChangeAddress()); + + for (int i = 0; i < 3; i++) { + outputs.add(halvedChangeOutput); + } + } else { + TransactionOutput changeOutput = new TransactionOutput(params, null, xRouterChangeAmt, blocknetWalletHelper.getChangeAddress()); + outputs.add(changeOutput); + } + + Transaction xRouterFeeTx = blocknetWalletHelper.createRawTransactionWithAllUTXOs(outputs, totalAvailable); + + String feetx = new String(Hex.encode(xRouterFeeTx.bitcoinSerialize())); + LOGGER.finer("[xrouter-fee-utils] XRouter fee transaction string representation:"); + LOGGER.finer(xRouterFeeTx.toString()); + LOGGER.finer("[xrouter-fee-utils] DEBUG: Feetx: " + feetx); + return feetx; + } + + public static TransactionOutput createXrSendTransactionFeeOutput(BlocknetPeer blocknetPeer) { + CoinInstance blocknetCoin = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()); + XRouterConfiguration xRouterConfig = blocknetPeer.getxRouterConfiguration(); + HashMap feeMap = xRouterConfig.getFeeMap(); + Preconditions.checkState(feeMap.containsKey("xrSendTransaction"), "Fee map has no fee for xrSendTransaction"); + + double fee = feeMap.get("xrSendTransaction"); + String feeAddress = xRouterConfig.getFeeAddress(); + + Coin feeAmount = Coin.valueOf(Sats.fromWholeCoins(fee)); + + return new TransactionOutput(blocknetCoin.getNetworkParameters(), null, feeAmount, LegacyAddress.fromBase58(blocknetCoin.getNetworkParameters(), feeAddress)); + } + + public static String coveredXrFee(BlocknetPeer blocknetPeer, Transaction transaction) { + CoinInstance blocknetCoin = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()); + WalletHelper.setAsSpent(blocknetCoin.getTicker(), transaction, true); + + String xrFee = getXRouterFeeTx(blocknetPeer, "xrSendTransaction"); + + if (xrFee.equals("nohash;nofee")) + return xrFee; + + Transaction tx = new Transaction(blocknetCoin.getNetworkParameters(), Hex.decode(xrFee)); + + if (tx.getInputs().size() == 0) + return null; + + return xrFee; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterInitialMessagesSentListener.java b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterInitialMessagesSentListener.java new file mode 100644 index 0000000..dc1109e --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterInitialMessagesSentListener.java @@ -0,0 +1,9 @@ +package io.xlite.daemon.app.net.xrouter; + +import io.xlite.daemon.app.net.CoinInstance; + +public interface XRouterInitialMessagesSentListener { + + void initialMessagesSent(CoinInstance instance); + +} diff --git a/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterMessage.java b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterMessage.java new file mode 100644 index 0000000..6ce2b95 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterMessage.java @@ -0,0 +1,442 @@ +package io.xlite.daemon.app.net.xrouter; + +import com.google.common.base.Preconditions; +import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetParameters; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import io.xlite.daemon.app.util.XRouterConfiguration; +import org.bitcoinj.core.Message; +import org.bitcoinj.core.ProtocolException; +import org.bitcoinj.core.Utils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class XRouterMessage extends Message { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private BlocknetPeer blocknetPeer; + + private XRouterPacketHeader xRouterHeader; + private byte[] data; + + private HashMap parsedData; + private BlocknetParameters params; + + public XRouterMessage(BlocknetParameters params, byte[] data) { + this.data = data; + + this.params = params; + this.parsedData = new HashMap<>(); + parseHeader(); + parse(); + } + + XRouterMessage(BlocknetPeer blocknetPeer, BlocknetParameters params, XRouterPacketHeader xRouterHeader, HashMap parsedData) { + this.blocknetPeer = blocknetPeer; + + this.xRouterHeader = xRouterHeader; + this.parsedData = parsedData; + + this.params = params; + this.data = bitcoinSerialize(); + } + + private void writeCurrency(HashMap body, OutputStream out) throws IOException { + out.write(((String) body.get("currency")).getBytes()); + out.write(0x00); + } + + private void writeAccountAndNumber(String account, String number, OutputStream out) throws IOException { + out.write(account.getBytes()); + out.write(0x00); + out.write(number.getBytes()); + out.write(0x00); + } + + /** + * Write Payment TX to unserialized XRouter Packet + * @param body HashMap The parsed XRouter packet body + * @param out OutputStream The output stream to which to write the serialized transaction info + * @throws IOException If writing fails or another error occurs + */ + private void writePaymentTx(HashMap body, OutputStream out) throws IOException { + if (!body.containsKey("paymentTx")) + return; + + out.write(((String) body.get("paymentTx")).getBytes()); + out.write(0x00); + } + + public XRouterPacketHeader getXRouterHeader() { + return xRouterHeader; + } + + public HashMap getParsedData() { + return parsedData; + } + + @Override + public byte[] bitcoinSerialize() { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + try { + bitcoinSerializeToStream(byteArrayOutputStream); + } catch (Exception e) { + LOGGER.warning("[xrouter] Error serializing XRouter packet" + e.getMessage()); + return null; + } + + return byteArrayOutputStream.toByteArray(); + } + + @Override + protected void bitcoinSerializeToStream(OutputStream stream) throws IOException { + if (xRouterHeader.getExtSize() < 253) { + stream.write(xRouterHeader.getCompactSizeBytes()); + } else if (xRouterHeader.getExtSize() <= 65535) { + stream.write((byte) 253); + stream.write(xRouterHeader.getCompactSizeBytes()); + } else { + stream.write((byte) 254); + stream.write(xRouterHeader.getCompactSizeBytes()); + } + + Utils.uint32ToByteStreamLE(xRouterHeader.getVersion(), stream); + Utils.uint32ToByteStreamLE(xRouterHeader.getCommand(), stream); + Utils.uint32ToByteStreamLE(xRouterHeader.getTimestamp(), stream); + Utils.uint32ToByteStreamLE(xRouterHeader.getSize(), stream); + stream.write(new byte[8]); + stream.write(xRouterHeader.getUUID().getBytes()); + stream.write(xRouterHeader.getPubkey()); + stream.write(xRouterHeader.getSignature()); + + switch (XRouterCommandUtils.commandIdToString(xRouterHeader.getCommand())) { + case "xrReply": + case "xrConfigReply": { + stream.write(((String) parsedData.get("reply")).getBytes()); + stream.write(0x00); + break; + } + case "xrGetReply": { + LOGGER.finer("[xrouter-message] DEBUG: Fetching reply for packet " + xRouterHeader.getUUID()); + break; + } + case "xrGetConfig": { + stream.write(((String) parsedData.get("addr")).getBytes()); + stream.write(0x00); + break; + } + case "xrGetBlockCount": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + Utils.uint32ToByteStreamLE(0, stream); //param count + break; + } + case "xrGetBlockHash": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + Utils.uint32ToByteStreamLE(1, stream); //param count + stream.write(((String) parsedData.get("blockId")).getBytes()); + stream.write(0x00); + break; + } + case "xrGetBlock": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + Utils.uint32ToByteStreamLE(1, stream); //param count + stream.write(((String) parsedData.get("blockHash")).getBytes()); + stream.write(0x00); + break; + } + case "xrGetTransaction": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + Utils.uint32ToByteStreamLE(1, stream); //param count + stream.write(((String) parsedData.get("txid")).getBytes()); + stream.write(0x00); + break; + } + case "xrSendTransaction": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + Utils.uint32ToByteStreamLE(1, stream); //param count + stream.write(((String) parsedData.get("transaction")).getBytes()); + stream.write(0x00); + break; + } + case "xrGetTxBloomFilter": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + stream.write(((String) parsedData.get("number_s")).getBytes()); + stream.write(0x00); + break; + } + case "xrGenerateBloomFilter": { + LOGGER.finer("[xrouter-message] ERROR: Attempted to serialize unsupported command 41."); + break; + } + case "xrGetBlocks": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + writeAccountAndNumber((String) parsedData.get("account"), (String) parsedData.get("number"), stream); + break; + } + case "xrGetTransactions": + case "xrGetBalanceUpdate": { + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + writeAccountAndNumber((String) parsedData.get("account"), (String) parsedData.get("number_s"), stream); + break; + } + case "xrGetBlockAtTime": { + LOGGER.finer("[xrouter-message] ERROR: Attempted to serialize unsupported command 52."); + break; + } + case "xrGetBalance": { //OBSOLETE, only implemented for backwards compatibility + writeCurrency(parsedData, stream); + writePaymentTx(parsedData, stream); + stream.write(((String) parsedData.get("account")).getBytes()); + stream.write(0x00); + break; + } + case "xrService": { + String command = (String) parsedData.get("command"); + LOGGER.finer("[xrService] Command: " + command); + + XRouterConfiguration.XRouterPluginConfiguration pluginConfig = blocknetPeer.getPluginConfig(command); + + if (pluginConfig == null) { + LOGGER.finer("[xrService] ERROR: Unsupported server xrs plugin: " + command); + LOGGER.finer("[xrService] ERROR: Aborting transmission."); + throw new IllegalArgumentException("Unsupported server xrs plugin: " + command); + } + + ArrayList pluginParamTypes = pluginConfig.getParamTypes(); + + stream.write(command.getBytes()); + stream.write(0x00); + + writePaymentTx(parsedData, stream); + + ArrayList params = (ArrayList) parsedData.get("params"); + + Utils.uint32ToByteStreamLE(params.size(), stream); //param count + + for (int i = 0; i < params.size(); i++) { + Class paramClass = pluginParamTypes.get(i); + Object param = params.get(i); + String classStr = XRouterConfiguration.getStringByClass(paramClass); + + if (!(param instanceof String && ((String) param).equalsIgnoreCase("true") || ((String) param).equalsIgnoreCase("false"))) + Preconditions.checkState(paramClass.isInstance(param), "Supplied parameter at index " + i + " is not of type '" + classStr + "'. Aborting transmission."); + + LOGGER.finer("[xrService] DEBUG: Parameter " + i + " is of type " + classStr); + + switch (classStr) { + case "string": { + String typedParam = (String) param; + stream.write(typedParam.getBytes()); + stream.write(0x00); + break; + } + case "bool": { + int typedParam; + + if (((String) param).equalsIgnoreCase("true")) + typedParam = 1; + else + typedParam = 0; + + Utils.uint32ToByteStreamLE(typedParam, stream); + break; + } + case "int": { + Integer typedParam = (Integer) param; + Utils.uint32ToByteStreamLE(typedParam, stream); + break; + } + default: { + LOGGER.finer("[xrService] ERROR: Encountered unhandled parameter of type " + classStr + ". Aborting transmission."); + throw new IllegalStateException("Bad parameter type at index " + i + ": " + classStr); + } + } + } + break; + } + default: { //xrInvalid + break; + } + } + } + + private String readStringNT(ByteBuffer in) { + StringBuilder stringBuilder = new StringBuilder(); + + byte buf; + + while ((buf = in.get()) != 0x00) { + stringBuilder.append((char) buf); + } + + return stringBuilder.toString(); + } + + private void readCurrency(ByteBuffer buf) { + String currency = readStringNT(buf); + parsedData.put("currency", currency); + } + + private void readPaymentTx(ByteBuffer buf) { + String paymentTx = readStringNT(buf); + parsedData.put("paymentTx", paymentTx); + } + + private void readAccountAndNumber(ByteBuffer buf, String secondFieldName) { + String account = readStringNT(buf); + parsedData.put("account", account); + offset += account.length(); + + String number = readStringNT(buf); + parsedData.put(secondFieldName, number); + offset += number.length(); + } + + private void parseHeader() throws ProtocolException { + xRouterHeader = new XRouterPacketHeader(ByteBuffer.wrap(data)); + } + + @Override + protected void parse() throws ProtocolException { + parsedData.put("header", xRouterHeader); + + LOGGER.finer("Received raw XRouter packet: " + new String(Hex.encode(data))); + ByteBuffer buf = ByteBuffer.wrap(data); + buf.position(xRouterHeader.getHeaderLength()); + + int command = xRouterHeader.getCommand(); + + switch (XRouterCommandUtils.commandIdToString(command)) { + case "xrReply": + case "xrConfigReply": { + String reply = readStringNT(buf); + parsedData.put("reply", reply); + LOGGER.finer("[xrouter-message] Got reply: '" + reply + "' for packet with UUID '" + xRouterHeader.getUUID() + "'"); + break; + } + case "xrGetReply": { + LOGGER.finer("[xrouter-message] WARNING: Server asked to fetch reply, but we aren't a server."); + break; + } + case "xrGetConfig": { + LOGGER.finer("[xrouter-message] WARNING: Server asked us for config, but we aren't a servicenode."); + break; + } + case "xrGetBlockCount": { + readCurrency(buf); + readPaymentTx(buf); + break; + } + case "xrGetBlockHash": { + readCurrency(buf); + + String blockId = readStringNT(buf); + parsedData.put("blockId", blockId); + + readPaymentTx(buf); + break; + } + case "xrGetBlock": { + readCurrency(buf); + + String blockHash = readStringNT(buf); + parsedData.put("blockHash", blockHash); + + readPaymentTx(buf); + break; + } + case "xrGetTransaction": { + readCurrency(buf); + + String txid = readStringNT(buf); + parsedData.put("txid", txid); + + readPaymentTx(buf); + break; + } + case "xrGetBlocks": { + readCurrency(buf); + readPaymentTx(buf); + + readAccountAndNumber(buf, "number"); + break; + } + case "xrGetTransactions": + case "xrGetBalanceUpdate": { + readCurrency(buf); + readPaymentTx(buf); + + readAccountAndNumber(buf, "number_s"); + break; + } + case "xrGetBalance": { //OBSOLETE + readCurrency(buf); + + String account = readStringNT(buf); + parsedData.put("account", account); + + readPaymentTx(buf); + break; + } + case "xrGetTxFilter": { + readCurrency(buf); + readPaymentTx(buf); + + String number_s = readStringNT(buf); + parsedData.put("number_s", number_s); + break; + } + case "xrSendTransaction": { + readCurrency(buf); + readPaymentTx(buf); + + String transaction = readStringNT(buf); + parsedData.put("transaction", transaction); + break; + } + case "xrGetBlockAtTime": { + readCurrency(buf); + + String timestamp = readStringNT(buf); + parsedData.put("timestamp", timestamp); + + readPaymentTx(buf); + break; + } + case "xrService": { + + String paymentTx = readStringNT(buf); + parsedData.put("command", paymentTx); + + ArrayList params = new ArrayList<>(); + while (buf.position() < buf.capacity()) { + String thisParam = readStringNT(buf); + params.add(thisParam); + } + + parsedData.put("params", params); + break; + } + default: { //xbcInvalid + break; + } + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterMessageSerializer.java b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterMessageSerializer.java new file mode 100644 index 0000000..d35588d --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterMessageSerializer.java @@ -0,0 +1,133 @@ +package io.xlite.daemon.app.net.xrouter; + +import com.google.common.base.Preconditions; +import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPacketHeader; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetParameters; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetUtils; +import org.bitcoinj.core.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class XRouterMessageSerializer extends MessageSerializer { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private boolean parseRetain; + private BlocknetParameters params; + + public XRouterMessageSerializer(boolean parseRetain, BlocknetParameters params) { + this.parseRetain = parseRetain; + this.params = params; + } + + @Override + public XRouterMessage deserialize(ByteBuffer in) throws ProtocolException, UnsupportedOperationException { + seekPastMagicBytes(in); + BlocknetPacketHeader header = deserializeHeader(in); + + return deserializePayload(header, in); + } + + @Override + public BlocknetPacketHeader deserializeHeader(ByteBuffer in) throws ProtocolException, UnsupportedOperationException { + return new BlocknetPacketHeader(in); + } + + @Override + public XRouterMessage deserializePayload(BitcoinSerializer.BitcoinPacketHeader header, ByteBuffer in) throws ProtocolException, BufferUnderflowException, UnsupportedOperationException { + BlocknetPacketHeader blocknetPacketHeader = (BlocknetPacketHeader) header; + + byte[] payloadBytes = new byte[blocknetPacketHeader.getLength()]; + in.get(payloadBytes, 0, payloadBytes.length); + + if (!BlocknetUtils.verifyChecksum(blocknetPacketHeader, payloadBytes)) { + throw new ProtocolException("XRouter packet's checksum failed to verify."); + } + + int dataLength = in.capacity() - in.position(); + + byte[] data = new byte[dataLength]; + in.get(data, 0, dataLength); + + return new XRouterMessage(params, data); + } + + @Override + public boolean isParseRetainMode() { + return parseRetain; + } + + @Override + public AddressMessage makeAddressMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer does not support address message construction."); + } + + @Override + public Message makeAlertMessage(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer does not support alert message construction."); + } + + @Override + public Block makeBlock(byte[] payloadBytes, int offset, int length) throws ProtocolException, UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer does not support block construction."); + } + + @Override + public Message makeBloomFilter(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer does not support bloom filter construction."); + } + + @Override + public FilteredBlock makeFilteredBlock(byte[] payloadBytes) throws ProtocolException, UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer does not support block construction."); + } + + @Override + public InventoryMessage makeInventoryMessage(byte[] payloadBytes, int length) throws ProtocolException, UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer does not support inventory message construction."); + } + + @Override + public Transaction makeTransaction(byte[] payloadBytes, int offset, int length, byte[] hash) throws ProtocolException, UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer does not support transaction construction"); + } + + @Override + public void serialize(String name, byte[] message, OutputStream out) throws UnsupportedOperationException { + throw new UnsupportedOperationException("This serializer currently does not support name/message serialization."); + } + + private void serialize(byte[] data, OutputStream out) throws IOException { + byte[] header = BlocknetUtils.getHeader("xrouter", (int) params.getPacketMagic(), data); + + out.write(header); + out.write(data); + + LOGGER.finer("[blocknet-serializer] Serialized xrouter message. Bytes: " + new String(Hex.encode(header)) + new String(Hex.encode(data))); + } + + /** + * Serialize XRouter packet header and body + * @param message XRouterMessage The XRouter message to serialize + * @param out OutputStream The output stream to which to write the serialized XRouter message bytes + * @throws IOException If writing fails or another I/O related error occurs + */ + @Override + public void serialize(Message message, OutputStream out) throws IOException { + Preconditions.checkArgument(message instanceof XRouterMessage, "This message is not an XRouter message."); + + XRouterMessage xRouterMessage = (XRouterMessage) message; + serialize(xRouterMessage.bitcoinSerialize(), out); + } + + @Override + public void seekPastMagicBytes(ByteBuffer in) throws BufferUnderflowException { + BlocknetUtils.seekPastMagicBytes(in, params); + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterPacketHeader.java b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterPacketHeader.java new file mode 100644 index 0000000..50e699e --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterPacketHeader.java @@ -0,0 +1,136 @@ +package io.xlite.daemon.app.net.xrouter; + +import com.subgraph.orchid.encoders.Hex; +import org.bitcoinj.core.Utils; + +import java.nio.ByteBuffer; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class XRouterPacketHeader { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private byte[] compactSizeBytes; + private int version; + private int command; + private int timestamp; + private int size; + private String uuid; + private byte[] pubkey; + private byte[] signature; + + private int headerLength; + + public XRouterPacketHeader(ByteBuffer in) { + byte compactSize = in.get(); + byte[] rawHeader; + in.rewind(); + int cursor = 1; + + if (compactSize == (byte) 253) { + rawHeader = new byte[160]; + in.get(rawHeader, 0, rawHeader.length); + compactSizeBytes = new byte[2]; + } else if (compactSize == (byte) 254) { + rawHeader = new byte[162]; + in.get(rawHeader, 0, rawHeader.length); + compactSizeBytes = new byte[4]; + } else if (compactSize == (byte) 255) { + rawHeader = new byte[166]; + in.get(rawHeader, 0, rawHeader.length); + compactSizeBytes = new byte[8]; + } else { + rawHeader = new byte[158]; + in.get(rawHeader, 0, rawHeader.length); + compactSizeBytes = new byte[1]; + cursor = 0; + } + + System.arraycopy(rawHeader, cursor, compactSizeBytes, 0, compactSizeBytes.length); + cursor += compactSizeBytes.length; + + LOGGER.finer("[xrouter] Retrieved compact size: " + new String(Hex.encode(new byte[]{compactSize}))); + LOGGER.finer("[xrouter] Retrieved compact size bytes: " + new String(Hex.encode(compactSizeBytes))); + + version = (int) Utils.readUint32(rawHeader, cursor); + cursor += 4; + LOGGER.finer("[xrouter] Retrieved version: " + version); + command = (int) Utils.readUint32(rawHeader, cursor); + cursor += 4; + LOGGER.finer("[xrouter] Retrieved command: " + command); + timestamp = (int) Utils.readUint32(rawHeader, cursor); + cursor += 4; + LOGGER.finer("[xrouter] Retrieved timestamp: " + timestamp); + size = (int) Utils.readUint32(rawHeader, cursor); + cursor += 4; + LOGGER.finer("[xrouter] Retrieved size: " + size); + + //reserved header fields + //we don't use these fields, so we skip them + cursor += 8; + + byte[] uuidArr = new byte[36]; + System.arraycopy(rawHeader, cursor, uuidArr, 0, uuidArr.length); + cursor += 36; + + uuid = new String(uuidArr); + LOGGER.finer("[xrouter] Retrieved UUID: " + uuid); + + byte[] pubkeyArr = new byte[33]; + System.arraycopy(rawHeader, cursor, pubkeyArr, 0, pubkeyArr.length); + cursor += 33; + LOGGER.finer("[xrouter] Retrieved pubkey: " + new String(Hex.encode(pubkeyArr))); + pubkey = pubkeyArr; + + byte[] sigArr = new byte[64]; + System.arraycopy(rawHeader, cursor, sigArr, 0, sigArr.length); + cursor += 64; + LOGGER.finer("[xrouter] Retrieved signature: " + new String(Hex.encode(sigArr))); + signature = sigArr; + + LOGGER.finer("[xrouter] XRouter header read complete, at position: " + cursor); + headerLength = cursor; + //should have read 157 bytes at this point (excluding compact size) + } + + byte[] getCompactSizeBytes() { + return compactSizeBytes; + } + + int getVersion() { + return version; + } + + public int getCommand() { + return command; + } + + int getTimestamp() { + return timestamp; + } + + int getExtSize() { + return size + 157; + } + + int getSize() { + return size; + } + + public String getUUID() { + return uuid; + } + + byte[] getPubkey() { + return pubkey; + } + + byte[] getSignature() { + return signature; + } + + int getHeaderLength() { + return headerLength; + } +} diff --git a/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterPacketManager.java b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterPacketManager.java new file mode 100644 index 0000000..5039b66 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/net/xrouter/XRouterPacketManager.java @@ -0,0 +1,260 @@ +package io.xlite.daemon.app.net.xrouter; + +import com.google.common.base.Preconditions; +import com.subgraph.orchid.encoders.Hex; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetParameters; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.core.Utils; + +import javax.annotation.Nullable; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class XRouterPacketManager { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private static final int XROUTER_PACKET_VERSION = 0xff000023; + + private final XRouterMessageSerializer xRouterMessageSerializer; + private final BlocknetParameters blocknetNetworkParameters; + + public XRouterPacketManager(XRouterMessageSerializer xRouterMessageSerializer, BlocknetParameters blocknetNetworkParameters) { + this.xRouterMessageSerializer = xRouterMessageSerializer; + this.blocknetNetworkParameters = blocknetNetworkParameters; + } + + public static int getXRouterPacketVersion() { + return XROUTER_PACKET_VERSION; + } + + private byte[] signPacket(byte[] packetBytes, ECKey ecPrivateKey) { + LOGGER.finer("[xrouter] DEBUG: Packet bytes: " + new String(Hex.encode(packetBytes))); + Sha256Hash packetHash = Sha256Hash.wrap(Sha256Hash.hash(packetBytes)); + LOGGER.finer("[xrouter] DEBUG: Packet byte hash: " + packetHash.toString()); + ECKey.ECDSASignature rawSignature = ecPrivateKey.sign(packetHash).toCanonicalised(); + + byte[] r = rawSignature.r.toByteArray(); + byte[] s = rawSignature.s.toByteArray(); + + if (r.length > 32) { + LOGGER.finer("[xrouter] WARNING: Signature R is greater than 32 bytes! Trimming from the beginning. Size: " + r.length); + LOGGER.finer("[xrouter] WARNING: Signature R: " + new String(Hex.encode(r))); + } else if (r.length < 32) { + LOGGER.finer("[xrouter] WARNING: Signature R is less than 32 bytes! Prepending null bytes to the beginning. Size: " + s.length); + LOGGER.finer("[xrouter] WARNING: Signature R: " + new String(Hex.encode(r))); + + r = prependNullTo32(r); + } + + if (s.length > 32) { + LOGGER.finer("[xrouter] WARNING: Signature S is greater than 32 bytes! Trimming from the beginning. Size: " + s.length); + LOGGER.finer("[xrouter] WARNING: Signature S: " + new String(Hex.encode(s))); + } else if (s.length < 32) { + LOGGER.finer("[xrouter] WARNING: Signature S is less than 32 bytes! Prepending null bytes. Size: " + s.length); + LOGGER.finer("[xrouter] WARNING: Signature S: " + new String(Hex.encode(s))); + + s = prependNullTo32(s); + } + + byte[] signature = new byte[64]; + + System.arraycopy(r, r.length - 32, signature, 0, 32); + System.arraycopy(s, s.length - 32, signature, 32, 32); + + LOGGER.finer("[xrouter] Signature: " + new String(Hex.encode(signature)) + ", byte length " + signature.length); + + return signature; + } + + private byte[] prependNullTo32(byte[] toPrependTo) { + Preconditions.checkState(toPrependTo.length < 32, "This array is too large (32 bytes or more)"); + + byte[] processed = new byte[32]; + + System.arraycopy(new byte[32 - toPrependTo.length], 0, processed, 0, 32 - toPrependTo.length); + System.arraycopy(toPrependTo, 0, processed, 32 - toPrependTo.length, toPrependTo.length); + + return processed; + } + + private XRouterMessage getPacket(BlocknetPeer blocknetPeer, int size, int commandId, String uuid, ECKey ecPublicKey, ECKey ecPrivateKey, HashMap body) { + Preconditions.checkNotNull(xRouterMessageSerializer); + Preconditions.checkNotNull(blocknetNetworkParameters); + + int extSize = size + 157; + + byte[] xRouterHeaderBytes; + byte compactSize; + int compactSizeBytes; + + if (extSize < 253) { + xRouterHeaderBytes = new byte[158]; + compactSize = (byte) extSize; + //LOGGER.finer("Compact size = " + compactSize); + xRouterHeaderBytes[0] = compactSize; + compactSizeBytes = 1; + } else if (extSize <= 65535) { + xRouterHeaderBytes = new byte[160]; + compactSize = (byte) 253; + //LOGGER.finer("Compact size = " + compactSize + ", extSize = " + extSize); + xRouterHeaderBytes[0] = compactSize; + xRouterHeaderBytes[1] = (byte) (0xFF & (extSize)); + xRouterHeaderBytes[2] = (byte) (0xFF & (extSize >> 8)); + compactSizeBytes = 3; + } else { + xRouterHeaderBytes = new byte[162]; + compactSize = (byte) 254; + //LOGGER.finer("Compact size = " + compactSize + ", extSize = " + extSize); + xRouterHeaderBytes[0] = compactSize; + Utils.uint32ToByteArrayLE(extSize, xRouterHeaderBytes, 1); + compactSizeBytes = 5; + } + + int cursor = compactSizeBytes; + //int cursor = 0; + + Utils.uint32ToByteArrayLE(XROUTER_PACKET_VERSION, xRouterHeaderBytes, cursor); + cursor += 4; + Utils.uint32ToByteArrayLE(commandId, xRouterHeaderBytes, cursor); + cursor += 4; + Utils.uint32ToByteArrayLE(Math.round(System.currentTimeMillis() / 1000), xRouterHeaderBytes, cursor); + cursor += 4; + Utils.uint32ToByteArrayLE(size, xRouterHeaderBytes, cursor); + cursor += 4; + + //fill in reserved header fields with zeros + System.arraycopy(new byte[8], 0, xRouterHeaderBytes, cursor, 8); + cursor += 8; + + System.arraycopy(uuid.getBytes(), 0, xRouterHeaderBytes, cursor, 36); + cursor += 36; + + byte[] pubkey = ecPublicKey.getPubKey(); + + System.arraycopy(pubkey, 0, xRouterHeaderBytes, cursor, 33); + cursor += 33; + + ByteBuffer xRouterHeaderBufSigned = ByteBuffer.allocate(xRouterHeaderBytes.length); + xRouterHeaderBufSigned.put(xRouterHeaderBytes); + xRouterHeaderBufSigned.position(cursor); + + System.arraycopy(new byte[64], 0, xRouterHeaderBytes, cursor, 64); + cursor += 64; + LOGGER.finer("[xrouter] Serialized XRouter header. Cursor is at " + cursor); + + LOGGER.finer("[xrouter] Serializing XRouter message (phase 1)."); + XRouterPacketHeader xRouterHeader = new XRouterPacketHeader(ByteBuffer.wrap(xRouterHeaderBytes)); + XRouterMessage message = new XRouterMessage(blocknetPeer, blocknetNetworkParameters, xRouterHeader, body); + + byte[] rawPktUnsigned = message.bitcoinSerialize(); + + byte[] toSign = new byte[rawPktUnsigned.length - compactSizeBytes]; + System.arraycopy(rawPktUnsigned, compactSizeBytes, toSign, 0, toSign.length); + + byte[] signature = signPacket(toSign, ecPrivateKey); + xRouterHeaderBufSigned.put(signature); + + xRouterHeaderBufSigned.flip(); + + LOGGER.finer("[xrouter] Serializing XRouter message (phase 2)."); + XRouterPacketHeader xRouterHeaderSigned = new XRouterPacketHeader(xRouterHeaderBufSigned); + return new XRouterMessage(blocknetPeer, blocknetNetworkParameters, xRouterHeaderSigned, body); + } + + private HashMap getBody(@Nullable String paymentTx) { + HashMap body = new HashMap<>(); + if (paymentTx != null) + body.put("paymentTx", paymentTx); + + return body; + } + + public XRouterMessage getXrGetBlockCount(BlocknetPeer blocknetPeer, String uuid, String currency, ECKey ecPrivateKey, ECKey ecPublicKey) { + String paymentTx = XRouterFeeUtils.getXRouterFeeTx(blocknetPeer, "xrGetBlockCount"); + + HashMap body = getBody(paymentTx); + body.put("currency", currency); + + int size = currency.length() + 1 + paymentTx.length() + 1 + 4; + + return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetBlockCount"), uuid, ecPublicKey, ecPrivateKey, body); + } + + public XRouterMessage getXrService(BlocknetPeer blocknetPeer, String uuid, String command, ArrayList params, ECKey ecPrivateKey, ECKey ecPublicKey) { + String paymentTx = XRouterFeeUtils.getXRouterFeeTx(blocknetPeer, "xrService"); + + HashMap body = getBody(paymentTx); + body.put("command", command); + body.put("params", params); + + int size = command.length() + 1 + paymentTx.length() + 1 + 4; + + for (Object param : params) { + if (param instanceof Boolean) + size += 4;else if (param instanceof String) + size += ((String) param).length() + 1;else if (param instanceof Integer) + size += 4; + else + throw new IllegalArgumentException("Argument of unsupported class: " + param.getClass().getSimpleName()); + } + + return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrService"), uuid, ecPublicKey, ecPrivateKey, body); + } + + public XRouterMessage getXrGetConfig(BlocknetPeer blocknetPeer, String uuid, String address, ECKey ecPrivateKey, ECKey ecPublicKey) { + HashMap body = getBody(null); + body.put("addr", address); + + int size = address.length() + 1; + + return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetConfig"), uuid, ecPublicKey, ecPrivateKey, body); + } + + public XRouterMessage getXrSendTransaction(BlocknetPeer blocknetPeer, String uuid, String feePayment, String currency, String transaction, ECKey ecPrivateKey, ECKey ecPublicKey) { + HashMap body = getBody(feePayment); + body.put("currency", currency); + body.put("transaction", transaction); + + int size = feePayment.length() + 1 + currency.length() + 1 + 4 + transaction.length() + 1; + + return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrSendTransaction"), uuid, ecPublicKey, ecPrivateKey, body); + } + + public XRouterMessage getXrGetBlockHash(BlocknetPeer blocknetPeer, String uuid, String feePayment, String currency, String blockIndex, ECKey ecPrivateKey, ECKey ecPublicKey) { + HashMap body = getBody(feePayment); + body.put("currency", currency); + body.put("blockId", blockIndex); + + int size = feePayment.length() + 1 + currency.length() + 1 + 4 + blockIndex.length() + 1; + + return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetBlockHash"), uuid, ecPublicKey, ecPrivateKey, body); + } + + public XRouterMessage getXrGetBlock(BlocknetPeer blocknetPeer, String uuid, String feePayment, String currency, String blockHash, ECKey ecPrivateKey, ECKey ecPublicKey) { + HashMap body = getBody(feePayment); + body.put("currency", currency); + body.put("blockHash", blockHash); + + int size = feePayment.length() + 1 + currency.length() + 1 + blockHash.length() + 1 + 4; + + return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetBlock"), uuid, ecPublicKey, ecPrivateKey, body); + } + + public XRouterMessage getXrGetTransaction(BlocknetPeer blocknetPeer, String uuid, String currency, String txid, ECKey ecPrivateKey, ECKey ecPublicKey) { + String paymentTx = XRouterFeeUtils.getXRouterFeeTx(blocknetPeer, "xrGetBlockCount"); + + HashMap body = getBody(paymentTx); + body.put("currency", currency); + body.put("txid", txid); + + int size = paymentTx.length() + 1 + currency.length() + 1 + txid.length() + 1 + 4; + + return getPacket(blocknetPeer, size, XRouterCommandUtils.commandStringToInt("xrGetTransaction"), uuid, ecPublicKey, ecPrivateKey, body); + } +} diff --git a/src/main/java/io/xlite/daemon/app/util/AddressBalance.java b/src/main/java/io/xlite/daemon/app/util/AddressBalance.java new file mode 100644 index 0000000..a567d20 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/AddressBalance.java @@ -0,0 +1,110 @@ +package io.xlite.daemon.app.util; + +import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.AtomicDouble; +import org.bitcoinj.core.DumpedPrivateKey; +import org.bitcoinj.core.LegacyAddress; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +public class AddressBalance { + private LegacyAddress address; + private DumpedPrivateKey privateKey; + private final AtomicReference addrProp; + private final AtomicDouble balanceProp; + private final CopyOnWriteArrayList utxos = new CopyOnWriteArrayList<>(); + + public AddressBalance(LegacyAddress address, DumpedPrivateKey privateKey) { + this.address = address; + this.privateKey = privateKey; + this.addrProp = new AtomicReference<>(address.toBase58()); + this.balanceProp = new AtomicDouble(0); + } + + public LegacyAddress getAddress() { + return address; + } + + public String getAddrProp() { + return addrProperty().get(); + } + + private void setAddrProp(String value) { + addrProperty().set(value); + } + + private AtomicReference addrProperty() { + return addrProp; + } + + public double getBalanceProp() { + return balanceProperty().get(); + } + + private void setBalanceProp(double value) { + balanceProperty().set(value); + } + + public AtomicDouble balanceProperty() { + return balanceProp; + } + + public DumpedPrivateKey getPrivateKey() { + return privateKey; + } + + public void clearPrivateKey() { + privateKey = null; + } + + public void clearUtxos() { + synchronized (this) { + utxos.removeIf(utxo -> !utxo.isSpent()); + } + } + + public boolean addUtxo(UTXO utxo) { + Preconditions.checkNotNull(utxo); + synchronized (this) { + // Only add UTXO's that do not exist in our wallet + UTXO bUtxo = getUtxo(utxo.getTxid(), utxo.getVout()); + if (bUtxo == null) + this.utxos.add(utxo); + else + return false; + + calculateBalance(); + return true; + } + } + + private UTXO getUtxo(String txid, int vout) { + return utxos.stream().filter(o -> o.getTxid().equals(txid) && o.getVout() == vout).findFirst().orElse(null); + } + + public List getSpentUtxos() { + return utxos.stream().filter(UTXO::isSpent).collect(Collectors.toList()); + } + + public List getUtxos() { + return utxos.stream().filter(utxo -> !utxo.isSpent()).collect(Collectors.toList()); + } + + public void calculateBalance() { + Preconditions.checkNotNull(utxos); + double balance = 0; + + for (UTXO utxo : utxos) { + if (!utxo.isSpent()) { + balance += utxo.getValue(); + } + } + + balance /= 100000000.0; + setBalanceProp(balance); + } + +} diff --git a/src/main/java/io/xlite/daemon/app/util/AddressDiscoveryService.java b/src/main/java/io/xlite/daemon/app/util/AddressDiscoveryService.java new file mode 100644 index 0000000..13e059a --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/AddressDiscoveryService.java @@ -0,0 +1,294 @@ +package io.xlite.daemon.app.util; + +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.net.api.http.client.HTTPClient; +import org.bitcoinj.core.DumpedPrivateKey; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.crypto.ChildNumber; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.crypto.HDKeyDerivation; +import org.bitcoinj.wallet.Wallet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class AddressDiscoveryService { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private static final int BATCH_SIZE = 500; + private static final int NUM_BATCHES = 100; // 500 * 100 = 50,000 address range + private static final int MAX_CONSECUTIVE_ERRORS = 3; + private static int DISCOVERY_TIMEOUT_MS = 30000; // non-final for testing + + public static int getBatchSize() { + return BATCH_SIZE; + } + + public static int getNumBatches() { + return NUM_BATCHES; + } + + private final CoinInstance coinInstance; + private final HTTPClient httpClient; + private final ConfigHelper configHelper; + private final String currencyString; + private DeterministicKey externalChainKey; + + private String getLogPrefix() { + return "[discovery-" + currencyString + "]"; + } + + public AddressDiscoveryService(CoinInstance coinInstance) { + this(coinInstance, new HTTPClient(5)); + } + + public AddressDiscoveryService(CoinInstance coinInstance, HTTPClient httpClient) { + this.coinInstance = coinInstance; + this.httpClient = httpClient; + this.configHelper = coinInstance.getConfigHelper(); + this.currencyString = CoinTickerUtils.tickerToString(coinInstance.getTicker()); + this.externalChainKey = initExternalChainKey(coinInstance.getWallet()); + LOGGER.finer(getLogPrefix() + " AddressDiscoveryService initialized for " + currencyString); + } + + /** + * Derive and cache the external chain key from the wallet seed. + * Clears the seed copy immediately after derivation per security conventions. + * Note: the wallet's original seed is never modified. + */ + private static DeterministicKey initExternalChainKey(Wallet wallet) { + byte[] seedBytes = wallet.getKeyChainSeed().getSeedBytes(); + if (seedBytes == null) { + return null; + } + byte[] seedCopy = Arrays.copyOf(seedBytes, seedBytes.length); + try { + DeterministicKey masterKey = HDKeyDerivation.createMasterPrivateKey(seedCopy); + ImmutableList accountPath = wallet.getActiveKeyChain().getAccountPath(); + DeterministicKey accountKey = masterKey; + for (ChildNumber child : accountPath) { + accountKey = HDKeyDerivation.deriveChildKey(accountKey, child); + } + return HDKeyDerivation.deriveChildKey(accountKey, ChildNumber.ZERO); + } finally { + Arrays.fill(seedCopy, (byte) 0); + } + } + + /** + * Clear the cached external chain key from memory after address discovery is complete. + */ + public void clearExternalChainKey() { + this.externalChainKey = null; + } + + public static void setDiscoveryTimeoutMs(int timeoutMs) { + DISCOVERY_TIMEOUT_MS = timeoutMs; + } + + /** + * Discovers the correct address count by scanning batches sequentially from batch 0. + * + * Each batch (100 addresses) is checked for any UTXOs. As long as a batch has funds, + * the scan continues to the next batch. The first empty batch marks the boundary. + * Within the last non-empty batch, the exact highest funded address is found. + * + * Returns the index of the last funded address + 1 as the discovered address count. + * Returns the current config value if batch 0 is empty (wallet unused) or on failure. + */ + public int discoverAddressCount() { + long startTime = System.currentTimeMillis(); + int currentAddressCount = configHelper.getAddressCount(); + + LOGGER.fine(getLogPrefix() + " Starting sequential batch scan"); + + try { + if (isTimedOut(startTime)) return currentAddressCount; + + int lastNonEmptyBatch = -1; + List lastBatchUtxos = null; + int consecutiveErrors = 0; + + for (int i = 0; i < NUM_BATCHES; i++) { + if (isTimedOut(startTime)) { + LOGGER.warning(getLogPrefix() + " Timeout at batch " + i); + break; + } + + List utxos = probeBatch(i); + + if (utxos == null) { + consecutiveErrors++; + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + LOGGER.warning(getLogPrefix() + + " Aborting: " + MAX_CONSECUTIVE_ERRORS + " consecutive HTTP failures"); + break; + } + continue; + } + + consecutiveErrors = 0; + if (utxos.isEmpty()) break; + + lastNonEmptyBatch = i; + lastBatchUtxos = utxos; + } + + if (lastNonEmptyBatch < 0) { + LOGGER.info(getLogPrefix() + " No UTXOs found"); + return currentAddressCount; + } + + // Find exact highest funded address in the last non-empty batch + int batchStart = lastNonEmptyBatch * BATCH_SIZE; + List batch = deriveAddressRange(batchStart, BATCH_SIZE); + + int lastUsedInBatch = findLastUsedIndexInBatch(batch, lastBatchUtxos); + int discoveredCount = batchStart + lastUsedInBatch + 1; + + for (AddressBalance addr : batch) { + addr.clearPrivateKey(); + } + + LOGGER.info(getLogPrefix() + " Discovery complete: lastBatch=" + + lastNonEmptyBatch + ", count=" + discoveredCount + + ", time=" + (System.currentTimeMillis() - startTime) + "ms"); + + return discoveredCount; + + } catch (Exception e) { + LOGGER.warning(getLogPrefix() + " Error during discovery" + e.getMessage()); + return currentAddressCount; + } + } + + /** + * Probe a batch for UTXOs. + * @return UTXO list (may be empty), or null on HTTP failure + */ + private List probeBatch(int batchIndex) { + int startIndex = batchIndex * BATCH_SIZE; + List batch = deriveAddressRange(startIndex, BATCH_SIZE); + try { + return checkBatchForUtxos(batch); + } finally { + for (AddressBalance addr : batch) { + addr.clearPrivateKey(); + } + } + } + + /** + * Derive addresses at specific HD indices without modifying wallet state. + * Uses the pre-derived external chain key, bypassing the wallet lookahead window limitation. + */ + private List deriveAddressRange(int startIndex, int count) { + List result = new ArrayList<>(count); + if (externalChainKey == null) { + return result; + } + NetworkParameters params = coinInstance.getNetworkParameters(); + + for (int i = 0; i < count; i++) { + int index = startIndex + i; + DeterministicKey addressKey = HDKeyDerivation.deriveChildKey(externalChainKey, new ChildNumber(index, false)); + LegacyAddress address = LegacyAddress.fromPubKeyHash(params, addressKey.getPubKeyHash()); + DumpedPrivateKey privKey = addressKey.getPrivateKeyEncoded(params); + result.add(new AddressBalance(address, privKey)); + } + return result; + } + + private boolean isTimedOut(long startTime) { + return (System.currentTimeMillis() - startTime) > DISCOVERY_TIMEOUT_MS; + } + + /** + * Check a batch of addresses for UTXOs via HTTP. + * @return list of UTXOs found (may be empty), or null on HTTP failure + */ + private List checkBatchForUtxos(List batch) { + if (batch.isEmpty()) { + return new ArrayList<>(); + } + String[] addresses = batch.stream() + .map(addr -> addr.getAddress().toBase58()) + .toArray(String[]::new); + JsonArray utxoResponse = null; + try { + utxoResponse = httpClient.getUtxosUncached(coinInstance.getTicker(), addresses); + } catch (Exception e) { + LOGGER.warning(getLogPrefix() + " HTTP request failed for addresses " + + addresses[0] + "..." + addresses[addresses.length - 1] + " - " + e.getMessage()); + return null; + } + if (utxoResponse == null || utxoResponse.size() == 0) { + return new ArrayList<>(); + } + List utxos = new ArrayList<>(); + for (JsonElement element : utxoResponse) { + try { + JsonObject utxoJson = element.getAsJsonObject(); + + JsonElement addressElement = utxoJson.get("address"); + JsonElement txidElement = utxoJson.get("txid"); + JsonElement voutElement = utxoJson.get("vout"); + JsonElement confirmationsElement = utxoJson.get("confirmations"); + JsonElement valueElement = utxoJson.get("value"); + + if (addressElement == null || txidElement == null || voutElement == null || + confirmationsElement == null || valueElement == null || + addressElement.isJsonNull() || txidElement.isJsonNull() || voutElement.isJsonNull() || + confirmationsElement.isJsonNull() || valueElement.isJsonNull()) { + LOGGER.warning(getLogPrefix() + " Skipping invalid UTXO - missing required fields"); + continue; + } + + UTXO utxo = new UTXO( + coinInstance.getTicker(), + addressElement.getAsString(), + txidElement.getAsString(), + voutElement.getAsInt(), + confirmationsElement.getAsInt(), + Sats.fromWholeCoins(valueElement.getAsDouble()) + ); + utxos.add(utxo); + } catch (Exception e) { + LOGGER.warning(getLogPrefix() + " Failed to parse UTXO response element: " + e.getMessage()); + } + } + return utxos; + } + + /** + * Find the highest address index within a batch that has UTXOs. + * Uses HashMap for O(1) lookups when batch size > 20. + */ + private int findLastUsedIndexInBatch(List batch, List utxos) { + Map addressToIndex = new HashMap<>(batch.size()); + for (int i = 0; i < batch.size(); i++) { + addressToIndex.put(batch.get(i).getAddress().toBase58(), i); + } + + int lastIndex = 0; + for (UTXO utxo : utxos) { + Integer index = addressToIndex.get(utxo.getAddress()); + if (index != null) { + lastIndex = Math.max(lastIndex, index); + } + } + return lastIndex; + } +} diff --git a/src/main/java/io/cloudchains/app/util/CCMath.java b/src/main/java/io/xlite/daemon/app/util/CCMath.java similarity index 83% rename from src/main/java/io/cloudchains/app/util/CCMath.java rename to src/main/java/io/xlite/daemon/app/util/CCMath.java index 64e98b3..1eb5967 100644 --- a/src/main/java/io/cloudchains/app/util/CCMath.java +++ b/src/main/java/io/xlite/daemon/app/util/CCMath.java @@ -1,4 +1,4 @@ -package io.cloudchains.app.util; +package io.xlite.daemon.app.util; public class CCMath { public static double roundToDecimalPlaces(double value, int decimalPlaces) { diff --git a/src/main/java/io/cloudchains/app/util/CloudTransaction.java b/src/main/java/io/xlite/daemon/app/util/CloudTransaction.java similarity index 97% rename from src/main/java/io/cloudchains/app/util/CloudTransaction.java rename to src/main/java/io/xlite/daemon/app/util/CloudTransaction.java index af324e7..d72d0c4 100644 --- a/src/main/java/io/cloudchains/app/util/CloudTransaction.java +++ b/src/main/java/io/xlite/daemon/app/util/CloudTransaction.java @@ -1,6 +1,6 @@ -package io.cloudchains.app.util; +package io.xlite.daemon.app.util; -import io.cloudchains.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinInstance; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.Transaction; import org.bitcoinj.utils.BtcFormat; diff --git a/src/main/java/io/xlite/daemon/app/util/ConfigHelper.java b/src/main/java/io/xlite/daemon/app/util/ConfigHelper.java new file mode 100644 index 0000000..56d132d --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/ConfigHelper.java @@ -0,0 +1,610 @@ +package io.xlite.daemon.app.util; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import io.xlite.daemon.app.App; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.stream.Stream; + +public class ConfigHelper { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private String tickerStr; + private File file; + + private long feePerByte; + private long minTxFee; + private boolean rpcEnabled; + private String rpcUsername; + private String rpcPassword; + private int rpcPort; + private int addressCount; + + // Override specific configuration directory (useful in unit tests) + public static String CONFIG_DIR = ""; // Must not end with [/], e.g. /home/user/.config, not /home/user/.config/ + + // Data directory names under the platform config root. + public static final String DATA_DIR_NAME = "xlite-daemon"; + private static final String LEGACY_DIR_NAME = "CloudChains"; + private static final String LEGACY_BACKUP_SUFFIX = ".bak"; + private static final String MIGRATION_STAGING_SUFFIX = ".migrating"; + // Staging directory name, derived from the data dir name. Public (like the + // lock file below) so the test harness can clean sandboxed leftovers. + public static final String MIGRATION_STAGING_NAME = DATA_DIR_NAME + MIGRATION_STAGING_SUFFIX; + // On-disk inter-process migration lock. Public so the test harness can + // clean it alongside the sandboxed data dir. Intentionally never deleted + // in production: an empty sentinel file is harmless, while deleting a + // lock another process may be opening is not. + public static final String MIGRATION_LOCK_FILE = ".migration.lock"; + private static final Object MIGRATION_LOCK = new Object(); + + public ConfigHelper(String tickerStr) { + this.tickerStr = Preconditions.checkNotNull(tickerStr, "tickerStr must not be null"); + + try { + file = Preconditions.checkNotNull(this.getFile(), "getFile() returned null for " + tickerStr); + loadConfig(); + } catch (Exception e) { + // Fail-closed: a null file means the data directory itself is + // unusable (migration or creation failed). Swallowing that here + // would boot the daemon on defaults with no wallet on disk. + if (file == null) { + throw new IllegalStateException( + "[config] Failed to initialize config for " + tickerStr + ": data directory unusable: " + + e.getMessage(), e); + } + LOGGER.warning("[config] Failed to initialize config for " + tickerStr + ", " + e.getMessage()); + } + } + + public synchronized void loadConfig() { + try { + String rawConfig = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + if (rawConfig.isEmpty()) { + rpcEnabled = false; + rpcUsername = ""; + rpcPassword = ""; + rpcPort = defaultRpcPort(); + addressCount = 0; + + writeConfig(); + return; + } + + JSONObject config = new JSONObject(rawConfig); + + final String[] configKeys = new String[]{ + "feeperbyte", + "mintxfee", + "rpcEnabled", + "rpcUsername", + "rpcPassword", + "rpcPort", + "addressCount" + }; + + for (String configKey : configKeys) { + if (!config.has(configKey)) { + LOGGER.finer("[config] Missing config key '" + configKey + "' for " + tickerStr + ", will use default"); + } + } + + boolean needsWrite = false; + + if (!config.has("feeperbyte")) { + needsWrite = true; + } else { + feePerByte = config.getLong("feeperbyte"); + } + + if (!config.has("mintxfee")) { + needsWrite = true; + } else { + minTxFee = config.getLong("mintxfee"); + } + + if (!config.has("rpcEnabled")) { + rpcEnabled = false; + needsWrite = true; + } else { + rpcEnabled = config.getBoolean("rpcEnabled"); + } + + if (!config.has("rpcUsername")) { + rpcUsername = ""; + needsWrite = true; + } else { + rpcUsername = config.getString("rpcUsername"); + } + + if (!config.has("rpcPassword")) { + rpcPassword = ""; + needsWrite = true; + } else { + rpcPassword = config.getString("rpcPassword"); + } + + if (!config.has("rpcPort")) { + rpcPort = defaultRpcPort(); + needsWrite = true; + } else { + rpcPort = config.getInt("rpcPort"); + if (rpcPort == 0) { + rpcPort = -1000; + needsWrite = true; + } + } + + if (!config.has("addressCount")) { + setAddressCount(0); + needsWrite = true; + } else { + addressCount = config.getInt("addressCount"); + } + + if (needsWrite) { + writeConfig(); + } + } catch (Exception e) { + LOGGER.warning("[config] Error reading config file for " + tickerStr + ", " + e.getMessage()); + } + } + + private File getFile() { + String userHome = getLocalDataDirectory(); + Preconditions.checkNotNull(userHome); + + File home = new File(userHome); + File settingsDirectory = new File(home, "settings"); + if (!settingsDirectory.exists()) { + if (!settingsDirectory.isDirectory() && !settingsDirectory.mkdirs() + && !settingsDirectory.isDirectory()) { + LOGGER.finer("[config] ERROR: Could not create base/settings directory!"); + return null; + } + } + // A regular file (or anything non-directory) at settings/ would make + // every config write below fail: refuse instead of booting on defaults. + if (!settingsDirectory.isDirectory()) { + LOGGER.warning("[config] settings path is not a directory: " + settingsDirectory); + return null; + } + + File configFile = new File(settingsDirectory, "config-" + tickerStr + ".json"); + try { + if (!configFile.createNewFile() && !configFile.exists()) + return null; + } catch (IOException e) { + LOGGER.warning("[config] IOException creating config file for " + tickerStr + ", " + e.getMessage()); + return null; + } + + return configFile; + } + + public synchronized void setFeePerByte(long feePerByte) { + this.feePerByte = feePerByte; + } + + public synchronized void setMinTxFee(long minTxFee) { + this.minTxFee = minTxFee; + } + + public synchronized void setRpcEnabled(boolean isEnabled) { + this.rpcEnabled = isEnabled; + } + + public synchronized void setRpcUsername(String user) { + this.rpcUsername = user; + } + + public synchronized void setRpcPassword(String pass) { + this.rpcPassword = pass; + } + + public synchronized boolean setRpcPort(int rpcPort) { + if (rpcPort < 1 || rpcPort > 65535) { + LOGGER.warning("[config] Invalid port " + rpcPort + ", must be 1-65535"); + return false; + } + int maxAttempts = 100; + for (int i = 0; i < maxAttempts && rpcPort + i <= 65535; i++) { + if (PortCheck.available(rpcPort + i)) { + this.rpcPort = rpcPort + i; + return true; + } + } + LOGGER.warning("[config] No available port in range " + rpcPort + "-" + Math.min(rpcPort + maxAttempts - 1, 65535)); + return false; + } + + public synchronized void setAddressCount(int addressCount) { + this.addressCount = addressCount; + } + + public synchronized long getFeePerByte() { + return feePerByte; + } + + public synchronized long getMinTxFee() { + return minTxFee; + } + + public synchronized boolean isRpcEnabled() { + return rpcEnabled; + } + + public synchronized String getRpcUsername() { + return rpcUsername; + } + + public synchronized String getRpcPassword() { + return rpcPassword; + } + + private int defaultRpcPort() { + return this.tickerStr.equalsIgnoreCase("master") ? 9955 : -1000; + } + + public synchronized int getMasterRpcPort() { + if (rpcPort == -1000) { + return 9955; + } + return rpcPort; + } + + public synchronized int getRpcPort() { + return rpcPort; + } + + public synchronized int getAddressCount() { + return addressCount; + } + + private JSONObject toConfigJson() { + JSONObject config = new JSONObject(); + config.put("feeperbyte", feePerByte); + config.put("mintxfee", minTxFee); + config.put("rpcEnabled", rpcEnabled); + config.put("rpcUsername", rpcUsername); + config.put("rpcPassword", rpcPassword); + config.put("rpcPort", rpcPort); + config.put("addressCount", addressCount); + return config; + } + + public synchronized boolean validAuth() { + return rpcUsername != null && !rpcUsername.isEmpty() && rpcPassword != null && !rpcPassword.isEmpty(); + } + + public synchronized void writeConfig() { + try { + String newContent = toConfigJson().toString(4); + + if (file.exists()) { + String existingContent = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + if (existingContent.equals(newContent)) { + return; + } + } + + try (FileWriter fw = new FileWriter(file, false)) { + fw.write(newContent); + } + } catch (IOException e) { + LOGGER.warning("[config] IOException writing config for " + tickerStr + ", " + e.getMessage()); + } + } + + public static String getLocalDataDirectory() { + String baseDir; + if (CONFIG_DIR == null || CONFIG_DIR.isEmpty()) { + baseDir = App.getUserConfigDir(); + } else { + baseDir = CONFIG_DIR; + } + // Fail fast on a null/blank base (e.g. missing Windows AppData) + // instead of stringifying to a bogus "null/xlite-daemon/" dir. + if (baseDir == null || baseDir.trim().isEmpty()) { + throw new IllegalStateException( + "[config] Cannot resolve data directory: base config dir is null or blank. " + + "Set XLITE_DATA_HOME to an explicit path."); + } + + File newDir = new File(baseDir, DATA_DIR_NAME); + File oldDir = new File(baseDir, LEGACY_DIR_NAME); + // Intra-JVM mutual exclusion plus an inter-process file lock: supervisors + // restart daemons, and two processes racing on the shared staging dir + // would otherwise wipe each other's in-progress copy. The loser of a + // real race blocks here, then finds the source already reconciled. + synchronized (MIGRATION_LOCK) { + File base = new File(baseDir); + // Re-check after mkdirs: a concurrent creator winning the race + // makes mkdirs return false for an existing dir (no failure). + if (!base.isDirectory() && !base.mkdirs() && !base.isDirectory()) { + throw new IllegalStateException( + "[config] Cannot create base config directory: " + base + + ". Check permissions or set XLITE_DATA_HOME."); + } + try (RandomAccessFile lockRaf = new RandomAccessFile(new File(base, MIGRATION_LOCK_FILE), "rw"); + FileLock migrationLock = acquireMigrationLock(lockRaf, base)) { + // Blocking acquire is intentional: the loser waits, then finds + // the source already reconciled. A second daemon only ever + // blocks here during an in-progress large copy. + migrateLegacyDirectory(baseDir, oldDir, newDir); + if (!newDir.isDirectory() && !newDir.mkdirs() && !newDir.isDirectory()) { + throw new IllegalStateException( + "[config] Cannot create data directory: " + newDir + + ". Check permissions or set XLITE_DATA_HOME."); + } + } catch (OverlappingFileLockException e) { + // Unreachable through our own paths (same-JVM overlap is + // excluded by MIGRATION_LOCK and the file is ours alone), + // but wrap precisely rather than leak an undecorated throw. + throw new IllegalStateException( + "[config] Data directory migration lock failed for " + base + ": overlapping lock", e); + } catch (IOException e) { + throw new IllegalStateException( + "[config] Data directory migration lock failed for " + base + ": " + e.getMessage(), e); + } + } + + return newDir.getPath() + File.separator; + } + + /** + * Blocking inter-process acquire with diagnosis logging. Blocking (not + * tryLock-with-timeout) is intentional: an OS-managed wait cannot + * spuriously fail a healthy startup; the loser always wakes to find the + * source already reconciled. + */ + private static FileLock acquireMigrationLock(RandomAccessFile lockRaf, File base) throws IOException { + LOGGER.fine("[migrate] waiting for data directory migration lock for " + base); + long startNanos = System.nanoTime(); + FileLock lock = lockRaf.getChannel().lock(); + long waitedMillis = (System.nanoTime() - startNanos) / 1_000_000; + // Operator-visible only when actually contended: uncontended startup stays quiet. + if (waitedMillis > 1000) { + LOGGER.info("[migrate] waited " + waitedMillis + " ms for data directory migration lock for " + base); + } else { + LOGGER.fine("[migrate] holding data directory migration lock for " + base); + } + return lock; + } + /** + * Reconciles the legacy {@code CloudChains/} data directory with the new + * {@code xlite-daemon/} one. Fail-closed: any unverifiable state throws + * instead of booting on a partial or empty directory. + * + *

States handled: + *

    + *
  • No legacy dir: nothing to do (fresh install or already migrated).
  • + *
  • Legacy dir, no new dir: same-filesystem rename, else staged + * copy + byte-verify + rename, then archive the legacy dir.
  • + *
  • Both dirs: merge-verify (idempotent; byte-identical files are + * fine), then archive the legacy dir. Byte-divergent files throw: + * either side could be the real wallet, so there is no safe choice.
  • + *
+ * + *

The legacy source is never deleted until its content is verified + * present at the destination; it is archived (never deleted) after that. + * Our own staging leftover is wiped and redone from the intact source. + */ + private static void migrateLegacyDirectory(String baseDir, File oldDir, File newDir) { + File staging = new File(baseDir, MIGRATION_STAGING_NAME); + if (staging.exists()) { + deleteDirectoryRecursively(staging); + } + if (!oldDir.exists()) { + return; + } + if (!newDir.exists()) { + try { + Files.move(oldDir.toPath(), newDir.toPath(), StandardCopyOption.ATOMIC_MOVE); + LOGGER.info("[migrate] moved legacy data directory from " + oldDir + " to " + newDir); + return; + } catch (AtomicMoveNotSupportedException e) { + LOGGER.warning("[migrate] atomic move unsupported (" + e.getMessage() + "), copy-verify-archive fallback"); + } catch (IOException e) { + LOGGER.warning("[migrate] move failed (" + e.getMessage() + "), copy-verify-archive fallback"); + } + copyVerifyRename(oldDir, newDir, staging); + archiveLegacyDir(baseDir, oldDir); + return; + } + // Both exist: refuse early when archiving is impossible, so the + // refusal below is side-effect-free (mergeVerify mutates newDir). + if (new File(baseDir, LEGACY_DIR_NAME + LEGACY_BACKUP_SUFFIX).exists()) { + throw new IllegalStateException( + "[migrate] refusing to boot: legacy backup already exists and legacy dir " + + oldDir + " is still present. Resolve manually."); + } + mergeVerify(oldDir, newDir); + archiveLegacyDir(baseDir, oldDir); + } + + /** + * Copies {@code oldDir} to {@code staging}, byte-verifies every legacy + * file landed identically, then renames staging to {@code newDir}. + * Throws on any failure; the legacy source is untouched throughout. + * + *

Visible for testing: the cross-filesystem fallback cannot be forced + * through the public path on a single test filesystem, so the suite + * drives this step directly with a hand-built staging dir. + */ + @VisibleForTesting + static void copyVerifyRename(File oldDir, File newDir, File staging) { + try { + copyDirectoryRecursively(oldDir, staging); + verifyTreeContains(staging, oldDir); + Files.move(staging.toPath(), newDir.toPath()); + } catch (IOException e) { + throw new IllegalStateException( + "[migrate] copy-verify-rename failed for " + oldDir + " -> " + newDir + ": " + e.getMessage(), e); + } + verifyTreeContains(newDir, oldDir); + LOGGER.info("[migrate] copied legacy data directory from " + oldDir + " to " + newDir); + } + + /** + * Merges legacy files missing from the new dir (never overwrites), then + * verifies every legacy file is present with identical bytes. Byte- + * divergent files throw: no automatic choice is safe for wallet data. + * + *

Conflict detection runs as a pure verification pass before any copy, + * so a refusal leaves the live dir untouched (fail-closed, side-effect-free). + */ + private static void mergeVerify(File oldDir, File newDir) { + List conflicts = detectConflicts(oldDir, newDir); + if (!conflicts.isEmpty()) { + throw new IllegalStateException( + "[migrate] refusing to boot: legacy and new data dirs diverge on " + + conflicts.size() + " file(s) " + conflicts + + ". Resolve manually (either side could be the real wallet)."); + } + try (Stream walk = Files.walk(oldDir.toPath())) { + for (Path source : (Iterable) walk::iterator) { + Path relative = oldDir.toPath().relativize(source); + Path target = newDir.toPath().resolve(relative); + if (Files.isDirectory(source)) { + if (!Files.exists(target)) { + Files.createDirectories(target); + } + } else if (Files.isRegularFile(source)) { + if (!Files.exists(target)) { + Files.copy(source, target); + } + } else { + throw new IllegalStateException( + "[migrate] unsupported legacy entry (not a file or directory): " + source); + } + } + } catch (IOException e) { + throw new IllegalStateException( + "[migrate] merge failed for " + oldDir + " -> " + newDir + ": " + e.getMessage(), e); + } + // Symmetric with the copy path: re-verify after writing, before the + // only other good copy is archived away. + verifyTreeContains(newDir, oldDir); + } + + /** + * Pure verification pass: returns relative paths of byte-divergent files + * present on both sides. Copies nothing. + */ + private static List detectConflicts(File oldDir, File newDir) { + List conflicts = new ArrayList<>(); + try (Stream walk = Files.walk(oldDir.toPath())) { + for (Path source : (Iterable) walk::iterator) { + if (!Files.isRegularFile(source)) { + continue; + } + Path relative = oldDir.toPath().relativize(source); + Path target = newDir.toPath().resolve(relative); + if (Files.isRegularFile(target) && Files.mismatch(source, target) != -1) { + conflicts.add(relative.toString()); + } + } + } catch (IOException e) { + throw new IllegalStateException( + "[migrate] conflict scan failed for " + oldDir + " vs " + newDir + ": " + e.getMessage(), e); + } + return conflicts; + } + + /** + * Every regular file under {@code required} must exist under + * {@code container} with identical bytes. One-directional: files created + * fresh in the container (e.g. logs) are fine. + */ + private static void verifyTreeContains(File container, File required) { + try (Stream walk = Files.walk(required.toPath())) { + for (Path source : (Iterable) walk::iterator) { + Path relative = required.toPath().relativize(source); + Path target = container.toPath().resolve(relative); + if (Files.isDirectory(source)) { + if (!Files.isDirectory(target)) { + throw new IllegalStateException("[migrate] verification failed: missing directory " + relative); + } + } else if (Files.isRegularFile(source)) { + if (!Files.isRegularFile(target) || Files.mismatch(source, target) != -1) { + throw new IllegalStateException("[migrate] verification failed: " + relative + " missing or divergent"); + } + } else { + throw new IllegalStateException("[migrate] verification failed: unsupported entry " + source); + } + } + } catch (IOException e) { + throw new IllegalStateException( + "[migrate] verification failed for " + required + " in " + container + ": " + e.getMessage(), e); + } + } + + /** + * Moves the reconciled legacy dir aside for rollback. Never deletes user + * data, never overwrites an existing backup: both throw fail-closed. + */ + private static void archiveLegacyDir(String baseDir, File oldDir) { + File backup = new File(baseDir, LEGACY_DIR_NAME + LEGACY_BACKUP_SUFFIX); + if (backup.exists()) { + throw new IllegalStateException( + "[migrate] refusing to boot: legacy backup already exists at " + backup + + " and legacy dir " + oldDir + " is still present. Resolve manually."); + } + try { + Files.move(oldDir.toPath(), backup.toPath()); + } catch (IOException e) { + throw new IllegalStateException( + "[migrate] failed to archive legacy dir " + oldDir + " to " + backup + ": " + e.getMessage(), e); + } + LOGGER.info("[migrate] archived legacy data directory from " + oldDir + " to " + backup); + } + + private static void deleteDirectoryRecursively(File dir) { + try (Stream walk = Files.walk(dir.toPath())) { + for (Path path : (Iterable) walk.sorted(Comparator.reverseOrder())::iterator) { + Files.delete(path); + } + } catch (IOException e) { + throw new IllegalStateException("[migrate] failed to clean staging dir " + dir + ": " + e.getMessage(), e); + } + } + + private static void copyDirectoryRecursively(File source, File target) throws IOException { + if (source.isDirectory()) { + if (!target.exists() && !target.mkdirs() && !target.exists()) { + throw new IOException("Failed to create directory " + target); + } + File[] children = source.listFiles(); + if (children != null) { + for (File child : children) { + copyDirectoryRecursively(child, new File(target, child.getName())); + } + } + } else { + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs() && !parent.exists()) { + throw new IOException("Failed to create directory " + parent); + } + Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/util/ConsoleFormatter.java b/src/main/java/io/xlite/daemon/app/util/ConsoleFormatter.java new file mode 100644 index 0000000..87fd03f --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/ConsoleFormatter.java @@ -0,0 +1,50 @@ +package io.xlite.daemon.app.util; + +import java.util.logging.Formatter; +import java.util.logging.LogRecord; + +/** + * Custom formatter for console output that produces clean, readable logs. + * Format: LEVEL: message + * Example: INFO: Wallet initialized successfully + * + * This formatter: + * - Uses English locale for consistent output + * - Excludes timestamps and class/method information + * - Provides clean, user-friendly console output + */ +public class ConsoleFormatter extends Formatter { + + private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + + @Override + public String format(LogRecord record) { + StringBuilder sb = new StringBuilder(); + + // Format: LEVEL: message + sb.append(record.getLevel().getName()); + sb.append(": "); + sb.append(formatMessage(record)); + sb.append(LINE_SEPARATOR); + + // Include thrown exception if present + if (record.getThrown() != null) { + try { + sb.append("Exception: "); + sb.append(record.getThrown().toString()); + sb.append(LINE_SEPARATOR); + + // Add stack trace + for (StackTraceElement element : record.getThrown().getStackTrace()) { + sb.append("\tat "); + sb.append(element.toString()); + sb.append(LINE_SEPARATOR); + } + } catch (Exception ex) { + // Ignore exceptions during exception formatting + } + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/io/cloudchains/app/util/DetectOS.java b/src/main/java/io/xlite/daemon/app/util/DetectOS.java similarity index 59% rename from src/main/java/io/cloudchains/app/util/DetectOS.java rename to src/main/java/io/xlite/daemon/app/util/DetectOS.java index ed7aa2d..1325a70 100644 --- a/src/main/java/io/cloudchains/app/util/DetectOS.java +++ b/src/main/java/io/xlite/daemon/app/util/DetectOS.java @@ -1,4 +1,4 @@ -package io.cloudchains.app.util; +package io.xlite.daemon.app.util; public class DetectOS { static public boolean isUnix = false; @@ -9,10 +9,8 @@ public class DetectOS { String OS = System.getProperty("os.name").toLowerCase(); if (OS.contains("win")) - isWindows = true; - else if (OS.contains("mac")) - isOSX = true; - else if (OS.contains("nix") || OS.contains("nux") || OS.contains("aix")) + isWindows = true;else if (OS.contains("mac")) + isOSX = true;else if (OS.contains("nix") || OS.contains("nux") || OS.contains("aix")) isUnix = true; } } diff --git a/src/main/java/io/xlite/daemon/app/util/FileFormatter.java b/src/main/java/io/xlite/daemon/app/util/FileFormatter.java new file mode 100644 index 0000000..daedc7c --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/FileFormatter.java @@ -0,0 +1,62 @@ +package io.xlite.daemon.app.util; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.logging.Formatter; +import java.util.logging.LogRecord; + +/** + * Custom formatter for file output that produces detailed logs with timestamps and class information. + * Format: yyyy-MM-dd HH:mm:ss class.method: message + * Example: 2025-12-12 12:01:51 io.xlite.daemon.app.console.ConsoleMenu.init: Wallet initialized + * + * This formatter: + * - Uses English locale for consistent date formatting + * - Includes full timestamp with seconds precision + * - Includes fully qualified class name and method + * - Provides detailed information for debugging and auditing + */ +public class FileFormatter extends Formatter { + + private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH); + + @Override + public String format(LogRecord record) { + StringBuilder sb = new StringBuilder(); + + // Format timestamp + sb.append(dateFormat.format(new Date(record.getMillis()))); + sb.append(" "); + + // Format class and method information + // Removed class and method information from log output + + // Format level and message + sb.append(record.getLevel().getName()); + sb.append(": "); + sb.append(formatMessage(record)); + sb.append(LINE_SEPARATOR); + + // Include thrown exception if present + if (record.getThrown() != null) { + try { + sb.append("Exception: "); + sb.append(record.getThrown().toString()); + sb.append(LINE_SEPARATOR); + + // Add stack trace + for (StackTraceElement element : record.getThrown().getStackTrace()) { + sb.append("\tat "); + sb.append(element.toString()); + sb.append(LINE_SEPARATOR); + } + } catch (Exception ex) { + // Ignore exceptions during exception formatting + } + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/io/xlite/daemon/app/util/LogRotationManager.java b/src/main/java/io/xlite/daemon/app/util/LogRotationManager.java new file mode 100644 index 0000000..0dfcafa --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/LogRotationManager.java @@ -0,0 +1,263 @@ +package io.xlite.daemon.app.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +/** + * Manages log file rotation and cleanup to prevent disk space issues. + * + * Features: + * - Configurable log retention period (default: 7 days) + * - Automatic cleanup of old log files during startup + * - Support for both .log and .log.1 files (rotated by FileHandler) + * - Graceful handling of missing directories and files + * - Detailed logging of cleanup operations + * + * Log file naming pattern: error-YYYY-MM-DD.log[.1] + * Example: error-2025-12-12.log, error-2025-12-12.log.1 + */ +public class LogRotationManager { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + private static final String LOG_PREFIX = "error-"; + private static final String LOG_SUFFIX = ".log"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final Path logDirectory; + private final int retentionDays; + + /** + * Creates a new LogRotationManager. + * + * @param logDirectoryPath Path to the directory containing log files + * @param retentionDays Number of days to keep log files (default: 7) + */ + public LogRotationManager(String logDirectoryPath, int retentionDays) { + this.logDirectory = Paths.get(logDirectoryPath); + this.retentionDays = Math.max(1, retentionDays); // Ensure at least 1 day retention + } + + /** + * Creates a LogRotationManager with default 7-day retention. + * + * @param logDirectoryPath Path to the directory containing log files + */ + public LogRotationManager(String logDirectoryPath) { + this(logDirectoryPath, 7); + } + + /** + * Performs log rotation cleanup by removing files older than the retention period. + * + * @return true if cleanup completed successfully, false otherwise + */ + public boolean rotateLogs() { + try { + if (!ensureLogDirectoryExists()) { + return false; + } + + List oldLogFiles = findOldLogFiles(); + + if (oldLogFiles.isEmpty()) { + LOGGER.info("[log-rotation] No old log files found. Current retention: " + retentionDays + " days"); + return true; + } + + LOGGER.info("[log-rotation] Found " + oldLogFiles.size() + " old log files to clean up"); + + long totalSize = 0; + int deletedCount = 0; + + for (File file : oldLogFiles) { + try { + long fileSize = file.length(); + if (file.delete()) { + totalSize += fileSize; + deletedCount++; + LOGGER.fine("[log-rotation] Deleted: " + file.getName() + " (" + fileSize + " bytes)"); + } else { + LOGGER.warning("[log-rotation] Failed to delete: " + file.getName()); + } + } catch (SecurityException e) { + LOGGER.severe("[log-rotation] Security exception deleting file: " + file.getName()); + } + } + + LOGGER.info("[log-rotation] Cleanup completed: " + deletedCount + "/" + oldLogFiles.size() + " files deleted, " + totalSize + " bytes freed"); + + return true; + + } catch (Exception e) { + LOGGER.severe("[log-rotation] Error during log rotation: " + e.getMessage()); + return false; + } + } + + /** + * Lists all log files in the directory with their details. + * + * @return List of log file details + */ + public List listLogFiles() { + List files = new ArrayList<>(); + + try { + if (!Files.exists(logDirectory)) { + return files; + } + + Files.list(logDirectory) + .filter(path -> isLogFile(path.toFile())) + .sorted(Comparator.comparing(Path::getFileName)) + .forEach(path -> { + File file = path.toFile(); + files.add(new LogFileInfo( + file.getName(), + file.length(), + file.lastModified() + )); + }); + + } catch (IOException e) { + LOGGER.warning("[log-rotation] Error listing log files"); + } + + return files; + } + + /** + * Gets the current retention period in days. + * + * @return Number of days logs are retained + */ + public int getRetentionDays() { + return retentionDays; + } + + /** + * Checks if a file is a log file based on naming pattern. + * + * @param file File to check + * @return true if it's a log file, false otherwise + */ + private boolean isLogFile(File file) { + String name = file.getName(); + return name.startsWith(LOG_PREFIX) && + name.endsWith(LOG_SUFFIX) && + extractDateFromFileName(name) != null; + } + + /** + * Extracts date from log file name. + * + * @param fileName Name of the log file + * @return LocalDate if valid, null otherwise + */ + private LocalDate extractDateFromFileName(String fileName) { + try { + // Remove prefix and suffix to get date part + String datePart = fileName + .replaceFirst("^" + LOG_PREFIX, "") + .replaceFirst("\\.log.*$", ""); + + return LocalDate.parse(datePart, DATE_FORMATTER); + } catch (DateTimeParseException e) { + LOGGER.fine("[log-rotation] Could not parse date from filename: " + fileName); + return null; + } + } + + /** + * Finds all log files older than the retention period. + * + * @return List of old log files + */ + private List findOldLogFiles() { + List oldFiles = new ArrayList<>(); + LocalDate cutoffDate = LocalDate.now().minusDays(retentionDays); + + try { + if (!Files.exists(logDirectory)) { + return oldFiles; + } + + Files.list(logDirectory) + .filter(path -> isLogFile(path.toFile())) + .forEach(path -> { + File file = path.toFile(); + LocalDate fileDate = extractDateFromFileName(file.getName()); + + if (fileDate != null && fileDate.isBefore(cutoffDate)) { + oldFiles.add(file); + } + }); + + } catch (IOException e) { + LOGGER.warning("[log-rotation] Error finding old log files"); + } + + return oldFiles; + } + + /** + * Ensures the log directory exists, creating it if necessary. + * + * @return true if directory exists or was created successfully + */ + private boolean ensureLogDirectoryExists() { + try { + if (!Files.exists(logDirectory)) { + Files.createDirectories(logDirectory); + LOGGER.info("[log-rotation] Created log directory: " + logDirectory); + } + return true; + } catch (IOException e) { + LOGGER.severe("[log-rotation] Failed to create log directory: " + logDirectory); + return false; + } + } + + /** + * Immutable class representing log file information. + */ + public static class LogFileInfo { + private final String name; + private final long size; + private final long lastModified; + + public LogFileInfo(String name, long size, long lastModified) { + this.name = name; + this.size = size; + this.lastModified = lastModified; + } + + public String getName() { + return name; + } + + public long getSize() { + return size; + } + + public long getLastModified() { + return lastModified; + } + + @Override + public String toString() { + return String.format("LogFileInfo{name='%s', size=%d bytes, lastModified=%d}", name, size, lastModified); + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/util/LogRotationUtil.java b/src/main/java/io/xlite/daemon/app/util/LogRotationUtil.java new file mode 100644 index 0000000..137dd3c --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/LogRotationUtil.java @@ -0,0 +1,79 @@ +package io.xlite.daemon.app.util; + +import io.xlite.daemon.app.App; + +import java.io.File; +import java.util.List; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +/** + * Utility class for log rotation management. + * Provides a simple interface to perform log cleanup during application startup. + */ +public class LogRotationUtil { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + private static final int DEFAULT_LOG_RETENTION_DAYS = 30; + private static final String LOG_RETENTION_ENV_VAR = "XLITE_DAEMON_LOG_RETENTION_DAYS"; + + /** + * Performs log rotation cleanup. + * This method should be called during application startup. + */ + public static void performLogRotation() { + try { + String userHomeDir = App.getUserConfigDir(); + String logDirectoryPath = userHomeDir + File.separator + ConfigHelper.DATA_DIR_NAME; + + // Get retention days from environment variable or use default + int retentionDays = getRetentionDaysFromEnvironment(); + + // Perform log rotation + LogRotationManager rotationManager = new LogRotationManager(logDirectoryPath, retentionDays); + boolean success = rotationManager.rotateLogs(); + + if (success) { + // Log current log files after rotation + List logFiles = rotationManager.listLogFiles(); + LOGGER.info("[log-rotation] Current log files after rotation: " + logFiles.size()); + for (LogRotationManager.LogFileInfo fileInfo : logFiles) { + LOGGER.fine("[log-rotation] " + fileInfo.getName() + " (" + fileInfo.getSize() + " bytes)"); + } + } else { + LOGGER.warning("[log-rotation] Log rotation completed with errors"); + } + + } catch (Exception e) { + LOGGER.severe("[log-rotation] Failed to perform log rotation: " + e.getMessage()); + } + } + + /** + * Gets the log retention period from environment variable or returns default. + * + * @return Number of days to retain logs + */ + private static int getRetentionDaysFromEnvironment() { + int retentionDays = DEFAULT_LOG_RETENTION_DAYS; + String retentionEnv = App.getEnv(LOG_RETENTION_ENV_VAR); + + if (retentionEnv != null && !retentionEnv.trim().isEmpty()) { + try { + int envRetention = Integer.parseInt(retentionEnv.trim()); + if (envRetention > 0) { + retentionDays = envRetention; + LOGGER.info("[log-rotation] Using retention period from environment: " + retentionDays + " days"); + } else { + LOGGER.warning("[log-rotation] Invalid retention period from environment: " + retentionEnv + ". Using default: " + DEFAULT_LOG_RETENTION_DAYS + " days"); + } + } catch (NumberFormatException e) { + LOGGER.warning("[log-rotation] Invalid retention period format from environment: " + retentionEnv + ". Using default: " + DEFAULT_LOG_RETENTION_DAYS + " days"); + } + } else { + LOGGER.info("[log-rotation] Using default retention period: " + retentionDays + " days"); + } + + return retentionDays; + } +} diff --git a/src/main/java/io/cloudchains/app/util/PortCheck.java b/src/main/java/io/xlite/daemon/app/util/PortCheck.java similarity index 92% rename from src/main/java/io/cloudchains/app/util/PortCheck.java rename to src/main/java/io/xlite/daemon/app/util/PortCheck.java index 909ed29..e930367 100644 --- a/src/main/java/io/cloudchains/app/util/PortCheck.java +++ b/src/main/java/io/xlite/daemon/app/util/PortCheck.java @@ -1,4 +1,4 @@ -package io.cloudchains.app.util; +package io.xlite.daemon.app.util; import java.io.IOException; import java.net.DatagramSocket; diff --git a/src/main/java/io/xlite/daemon/app/util/Sats.java b/src/main/java/io/xlite/daemon/app/util/Sats.java new file mode 100644 index 0000000..8c7e0d8 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/Sats.java @@ -0,0 +1,21 @@ +package io.xlite.daemon.app.util; + +import org.bitcoinj.core.Coin; + +/** + * Whole-coin doubles to integer base units. Single choke point so every + * path (ingest, send, fees, display) converts identically. + * + *

Must round to nearest: binary doubles cannot exactly represent most + * decimal fractions, and truncating the representation error loses + * satoshis (e.g. 0.00050001 becomes 50000 instead of 50001). + */ +public final class Sats { + + private Sats() { + } + + public static long fromWholeCoins(double whole) { + return Math.round(whole * Coin.COIN.value); + } +} diff --git a/src/main/java/io/xlite/daemon/app/util/UTXO.java b/src/main/java/io/xlite/daemon/app/util/UTXO.java new file mode 100644 index 0000000..68ff20a --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/UTXO.java @@ -0,0 +1,84 @@ +package io.xlite.daemon.app.util; + +import com.google.gson.annotations.SerializedName; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; + +public class UTXO { + @SerializedName("address") + private String addressB58; + @SerializedName("txhash") + private String txid; + @SerializedName("block_number") + private int height; + protected transient long value; + private int vout; + private volatile boolean spent; + protected CoinTicker ticker; + + public UTXO(CoinTicker ticker, String addressB58, String txid, int vout, int blockHeight, long value) { + this.addressB58 = addressB58; + this.txid = txid; + this.vout = vout; + this.height = blockHeight; + this.value = value; + this.spent = false; + + this.ticker = ticker; + } + + public void setSpent(boolean spentBool) { + spent = spentBool; + } + + public String getAddress() { + return addressB58; + } + + public String getTxid() { + return txid; + } + + public long getValue() { + return value; + } + + public int getVout() { + return vout; + } + + public int getHeight() { + return height; + } + + public boolean isSpent() { + return spent; + } + + public double getAmount() { + return getValue() / 100000000.0; + } + + public org.bitcoinj.core.UTXO createUTXO() { + LegacyAddress address = LegacyAddress.fromBase58(CoinInstance.getInstance(this.ticker).getNetworkParameters(), getAddress()); + + Script scriptForUTXO = ScriptBuilder.createOutputScript(address); + + Sha256Hash sha256Hash = Sha256Hash.wrap(getTxid()); + return new org.bitcoinj.core.UTXO(sha256Hash, getVout(), Coin.valueOf(getValue()), getHeight(), false, scriptForUTXO, getAddress()); + } + + public String toString() { + return "\n-------------------------\n" + + "Address=" + getAddress() + + "\nTXHash=" + getTxid() + + "\nVout=" + getVout() + + "\nBlockNumber=" + getHeight() + + "\nValue=" + getValue(); + } +} diff --git a/src/main/java/io/xlite/daemon/app/util/Utility.java b/src/main/java/io/xlite/daemon/app/util/Utility.java new file mode 100644 index 0000000..96ad249 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/Utility.java @@ -0,0 +1,27 @@ +package io.xlite.daemon.app.util; + +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.SegwitAddress; + +public class Utility { + public static boolean isValidAddress(NetworkParameters params, String address) { + if (address == null || address.isEmpty()) + return false; + + try { + LegacyAddress.fromBase58(params, address); + return true; + } catch (AddressFormatException ignored) { + // fall through to segwit check + } + + try { + SegwitAddress.fromBech32(params, address); + return true; + } catch (AddressFormatException e) { + return false; + } + } +} diff --git a/src/main/java/io/xlite/daemon/app/util/XRouterConfiguration.java b/src/main/java/io/xlite/daemon/app/util/XRouterConfiguration.java new file mode 100644 index 0000000..2a1307b --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/XRouterConfiguration.java @@ -0,0 +1,213 @@ +package io.xlite.daemon.app.util; + +import com.google.common.collect.HashBiMap; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Properties; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class XRouterConfiguration { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private final String rawXRouterConfig; + private final HashMap feeMap = new HashMap<>(); + private final ArrayList supportedWallets = new ArrayList<>(); + private String feeAddress; + private int timeout; + private int blockLimit; + + private static HashBiMap pluginParamTypes; + + static { + pluginParamTypes = HashBiMap.create(2); + + pluginParamTypes.put("string", String.class); + pluginParamTypes.put("int", Integer.class); + pluginParamTypes.put("bool", Boolean.class); + } + + public static String getStringByClass(Class clazz) { + return pluginParamTypes.inverse().get(clazz); + } + + public static class XRouterPluginConfiguration { + private final String rawPluginConfig; + private final String pluginName; + + private ArrayList paramTypes = new ArrayList<>(); + private double fee; + private int clientRequestLimit; + + public XRouterPluginConfiguration(String pluginName, String rawPluginConfig) { + this.pluginName = pluginName; + this.rawPluginConfig = rawPluginConfig; + } + + public void parsePluginConfig() { + Properties properties; + try { + properties = getPluginProperties(rawPluginConfig); + } catch (IOException e) { + LOGGER.warning("[xrouter-config] Failed to parse plugin config for " + pluginName + ": " + e.getMessage()); + return; + } + + if (!properties.containsKey("parameters") && !properties.containsKey("paramsType")) { + LOGGER.finer("[xrouter-plugin-config-parser] ERROR: Plugin has no parameters!"); + } else { + String[] rawParamTypes; + + if (properties.containsKey("parameters")) + rawParamTypes = properties.getProperty("parameters").split(",");else if (properties.containsKey("paramsType")) + rawParamTypes = properties.getProperty("paramsType").split(","); + else + return; + + for (String rawParamType : rawParamTypes) { + if (rawParamType.isEmpty()) + continue; + + if (!pluginParamTypes.containsKey(rawParamType)) { + LOGGER.finer("[xrouter-plugin-config-parser] ERROR: Invalid/unsupported plugin parameter type: " + rawParamType + ". Failing."); + throw new IllegalArgumentException("Invalid/unsupported plugin parameter type: " + rawParamType); + } + + paramTypes.add(pluginParamTypes.get(rawParamType)); + } + } + + if (properties.contains("fee")) { + fee = Double.parseDouble(properties.getProperty("fee")); + } else { + fee = 0; + } + + if (properties.contains("clientrequestlimit")) { + clientRequestLimit = Integer.parseInt(properties.getProperty("clientrequestlimit")); + } else { + clientRequestLimit = 100; + } + + LOGGER.finer("[xrouter-plugin-config-parser] Processing '" + pluginName + "' complete."); + LOGGER.finer("[xrouter-plugin-config-parser] DEBUG: " + pluginName + ": fee = " + fee); + LOGGER.finer("[xrouter-plugin-config-parser] DEBUG: " + pluginName + ": params = "); + for (int i = 0; i < paramTypes.size(); i++) { + LOGGER.finer("Parameter " + i + ":\t" + paramTypes.get(i).getSimpleName()); + } + LOGGER.finer("[xrouter-plugin-config-parser] DEBUG: " + pluginName + ": clientRequestLimit = " + clientRequestLimit); + } + + private static Properties getPluginProperties(String rawConfig) throws IOException { + String toRead = rawConfig.replace("\\n", "\n"); + + Properties properties = new Properties(); + properties.load(new StringReader(toRead)); + return properties; + } + + public String getPluginName() { + return pluginName; + } + + public ArrayList getParamTypes() { + return paramTypes; + } + + public double getFee() { + return fee; + } + + public int getClientRequestLimit() { + return clientRequestLimit; + } + } + + public XRouterConfiguration(String rawXRouterConfig) { + this.rawXRouterConfig = rawXRouterConfig; + } + + public void parseConfig() { + HashMap properties = getProperties(rawXRouterConfig); + + if (properties == null) + return; + + LOGGER.finer("[xrouter-config-parser] DEBUG: Properties: " + properties.toString()); + + supportedWallets.addAll(Arrays.asList(((String) properties.get("Main").get("wallets")).split(","))); + timeout = Integer.parseInt((String) properties.get("Main").get("timeout")); + blockLimit = Integer.parseInt((String) properties.get("Main").get("blocklimit")); + feeAddress = (String) properties.get("Main").get("paymentaddress"); + + for (String key : properties.keySet()) { + if (key.startsWith("xr")) { + Properties xRouterPropertySet = properties.get(key); + double fee = Double.parseDouble((String) xRouterPropertySet.get("fee")); + feeMap.put(key, fee); + } + } + + LOGGER.finer("[xrouter-config-parser] Processing complete."); + } + + public ArrayList getSupportedWallets() { + return supportedWallets; + } + + public HashMap getFeeMap() { + return feeMap; + } + + public String getFeeAddress() { + return feeAddress; + } + + public int getBlockLimit() { + return blockLimit; + } + + public int getTimeout() { + return timeout; + } + + private static HashMap parseINI(String toRead) throws IOException { + HashMap result = new HashMap<>(); + new Properties() { + + private Properties section; + + @Override + public Object put(Object key, Object value) { + String header = (key + " " + value).trim(); + if (header.startsWith("[") && header.endsWith("]")) + return result.put(header.substring(1, header.length() - 1), + section = new Properties()); + else + return section.put(key, value); + } + + }.load(new StringReader(toRead)); + return result; + } + + private static HashMap getProperties(String rawConfig) { + String formatted = rawConfig.replace("\\n", "\n"); + + HashMap properties; + + try { + properties = parseINI(formatted); + } catch (IOException e) { + LOGGER.warning("[xrouter-config] Failed to parse XRouter config: " + e.getMessage()); + return null; + } + + return properties; + } +} diff --git a/src/main/java/io/xlite/daemon/app/util/background/BackgroundTimerThread.java b/src/main/java/io/xlite/daemon/app/util/background/BackgroundTimerThread.java new file mode 100644 index 0000000..b2a0567 --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/util/background/BackgroundTimerThread.java @@ -0,0 +1,272 @@ +package io.xlite.daemon.app.util.background; + +import io.xlite.daemon.app.App; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.net.api.http.client.HTTPClient; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeerGroup; +import io.xlite.daemon.app.util.LogRotationUtil; +import io.xlite.daemon.app.util.XRouterConfiguration; + +import java.time.Duration; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class BackgroundTimerThread implements Runnable { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + public static final boolean HTTP_BLOCK_COUNT_UPDATES = true; + public static final boolean HTTP_BALANCE_UPDATES = true; + + private static final int KEEPALIVE_INTERVAL = 10000; + private static final int BALANCE_INTERVAL = 10000; + + private BlocknetPeerGroup blocknetPeerGroup; + private HTTPClient feeUpdateHttpClient; + private HTTPClient heightUpdateHttpClient; + + private long lastKeepAliveTime; + private long lastBalanceUpdateTime; + + private long lastOut; + private boolean shutdownRequested = false; + private volatile Thread workerThread; + + private Set lastAvailable = new HashSet<>(); + private Set lastUnavailable = new HashSet<>(); + + // Daemon-factory scheduler: the wrapper thread started by ConsoleMenu + // is the deliberate sole non-daemon anchor of this application. Worker + // threads must never outlive it as liveness anchors — stop() still + // releases the scheduler explicitly, but daemon status makes the + // single-anchor invariant structural rather than remembered. + private ScheduledExecutorService logRotationScheduler; + private static final int DAILY_ROTATION_HOUR = 2; // 2:00 AM + private static final int DAILY_ROTATION_MINUTE = 0; + + public BackgroundTimerThread() { + blocknetPeerGroup = CoinInstance.getInstance(CoinInstance.getActiveBlocknetNetwork()).getBlocknetPeerGroup(); + feeUpdateHttpClient = App.feeUpdateHttpClient; + heightUpdateHttpClient = App.heightUpdateHttpClient; + + lastKeepAliveTime = 0; + lastBalanceUpdateTime = 0; + + lastOut = 0; + + // Initialize log rotation scheduler + initializeLogRotationScheduler(); + } + + /** + * Initializes the log rotation scheduler to run daily at 2:00 AM. + */ + private void initializeLogRotationScheduler() { + logRotationScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "background-timer-log-rotation"); + t.setDaemon(true); + return t; + }); + long initialDelay = calculateInitialDelay(); + logRotationScheduler.scheduleAtFixedRate( + this::performDailyLogRotation, + initialDelay, + 24, TimeUnit.HOURS + ); + LocalTime now = LocalTime.now(); + LOGGER.info("[BackgroundTimer] Scheduled daily log rotation at " + String.format("%02d:%02d", DAILY_ROTATION_HOUR, DAILY_ROTATION_MINUTE) + " (current time: " + String.format("%02d:%02d", now.getHour(), now.getMinute()) + ")"); + } + + /** + * Calculates the initial delay until the next scheduled log rotation at 2:00 AM. + * + * @return Delay in milliseconds until next 2:00 AM + */ + private long calculateInitialDelay() { + LocalTime now = LocalTime.now(); + LocalTime targetTime = LocalTime.of(DAILY_ROTATION_HOUR, DAILY_ROTATION_MINUTE); + long delay; + if (now.isBefore(targetTime)) { + delay = Duration.between(now, targetTime).toMillis(); + } else { + delay = Duration.between(now, targetTime.plusHours(24)).toMillis(); + } + return Math.max(delay, 0); + } + + /** + * Performs the daily log rotation task. + * Called by the scheduler every 24 hours at 2:00 AM. + */ + private void performDailyLogRotation() { + try { + LOGGER.info("[BackgroundTimer] Starting scheduled daily log rotation"); + LogRotationUtil.performLogRotation(); + LOGGER.info("[BackgroundTimer] Daily log rotation completed successfully"); + } catch (Exception e) { + LOGGER.severe("[BackgroundTimer] Failed to perform daily log rotation: " + e.getMessage()); + } + } + + public void stop() { + shutdownRequested = true; + if (workerThread != null) { + workerThread.interrupt(); + } + if (logRotationScheduler != null && !logRotationScheduler.isShutdown()) { + logRotationScheduler.shutdown(); + try { + if (!logRotationScheduler.awaitTermination(5, TimeUnit.SECONDS)) { + logRotationScheduler.shutdownNow(); + } + } catch (InterruptedException e) { + logRotationScheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + + private void outputAvailableCurrencies() { + long elapsed = (System.currentTimeMillis() - lastOut); + + if (elapsed < 60 * 1000 && lastOut != 0) + return; + + List available = new ArrayList<>(); + Set unavailable = new HashSet<>(); + + for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { + if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) + continue; + + String name = CoinTickerUtils.tickerToString(coinInstance.getTicker()); + if (CoinInstance.getBlockCountByTicker(coinInstance.getTicker()) > 0) { + available.add(name); + } else { + unavailable.add(name); + } + } + + Set currentAvailable = new HashSet<>(available); + if (!currentAvailable.equals(lastAvailable)) { + LOGGER.info("[coin] Available: " + String.join(", ", available)); + } + lastAvailable = currentAvailable; + + if (!unavailable.isEmpty() && !unavailable.equals(lastUnavailable)) { + LOGGER.info("[coin] Unavailable: " + String.join(", ", unavailable)); + } + lastUnavailable = unavailable; + lastOut = System.currentTimeMillis(); + } + + private void sendKeepAlive() { + long elapsed = (System.currentTimeMillis() - lastKeepAliveTime); + + if (elapsed < KEEPALIVE_INTERVAL && lastKeepAliveTime != 0) + return; + + if (HTTP_BLOCK_COUNT_UPDATES) { + heightUpdateHttpClient.getAllBlockCounts(); + } else if (!blocknetPeerGroup.getConnectedPeers().isEmpty()) { + for (BlocknetPeer blocknetPeer : blocknetPeerGroup.getConnectedPeers()) { + XRouterConfiguration xRouterConfiguration = blocknetPeer.getxRouterConfiguration(); + if (xRouterConfiguration == null) + continue; + + for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { + if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) + continue;else if (!blocknetPeer.getxRouterConfiguration().getSupportedWallets().contains(coinInstance.getNetworkParameters().getId())) + continue; + + coinInstance.sendXrGetBlockCount(blocknetPeer); + LOGGER.finer("[BackgroundTimer] Sent keepalive message: " + coinInstance.getNetworkParameters().getId()); + } + } + } else { + return; + } + + lastKeepAliveTime = System.currentTimeMillis(); + } + + private void sendBalanceUpdate() { + long elapsed = (System.currentTimeMillis() - lastBalanceUpdateTime); + + if (elapsed < BALANCE_INTERVAL && lastBalanceUpdateTime != 0) + return; + + // No longer polling balances and transaction history here. Instead it is requested + // on demand when client requests the data. See HTTPServerHandler.java:302-330 + + for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { + if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) + continue; + + if (CoinInstance.getBlockCountByTicker(coinInstance.getTicker()) <= 0) { + continue; + } + + if (blocknetPeerGroup.getConnectedPeers().isEmpty()) { + return; + } + + BlocknetPeer blocknetPeer = blocknetPeerGroup.getBestBlocknetPeer(coinInstance.getNetworkParameters().getId()); + if (blocknetPeer == null) { + LOGGER.finer("[BackgroundTimer] Peer was not found for currency " + coinInstance.getNetworkParameters().getId()); + continue; + } + + coinInstance.sendXrGetUtxos(blocknetPeer); + LOGGER.finer("[BackgroundTimer] Sent GetUtxos message: " + coinInstance.getNetworkParameters().getId()); + } + + lastBalanceUpdateTime = System.currentTimeMillis(); + } + + @Override + public void run() { + workerThread = Thread.currentThread(); + LOGGER.finer("[BackgroundTimer] Waiting until initial messages are sent off."); + + for (CoinInstance coinInstance : CoinInstance.getCoinInstances()) { + if (!CoinTickerUtils.isActiveTicker(coinInstance.getTicker())) + continue; + + Thread historyThread = new Thread(() -> { + App.feeUpdateHttpClient.getHistory(coinInstance.getTicker(), 0, (int) System.currentTimeMillis(), 30000); + }, "history-fetch-" + CoinTickerUtils.tickerToString(coinInstance.getTicker())); + // Daemon: a blocking initial-history fetch must never keep the + // JVM alive past shutdown. + historyThread.setDaemon(true); + historyThread.start(); + } + + while (!Thread.currentThread().isInterrupted()) { + if (shutdownRequested) + break; + try { + sendKeepAlive(); + outputAvailableCurrencies(); + + Thread.sleep(100); + } catch (InterruptedException e) { + break; + } catch (NullPointerException e) { + LOGGER.warning("[BackgroundTimer] Null pointer: " + e.getMessage()); + } catch (Exception e) { + LOGGER.warning("[BackgroundTimer] Unexpected error: " + e.getMessage()); + } + } + } +} diff --git a/src/main/java/io/cloudchains/app/util/history/Transaction.java b/src/main/java/io/xlite/daemon/app/util/history/Transaction.java similarity index 85% rename from src/main/java/io/cloudchains/app/util/history/Transaction.java rename to src/main/java/io/xlite/daemon/app/util/history/Transaction.java index 7031455..f5f2351 100644 --- a/src/main/java/io/cloudchains/app/util/history/Transaction.java +++ b/src/main/java/io/xlite/daemon/app/util/history/Transaction.java @@ -1,15 +1,19 @@ -package io.cloudchains.app.util.history; +package io.xlite.daemon.app.util.history; import com.google.gson.annotations.SerializedName; -import io.cloudchains.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTicker; import java.util.List; public class Transaction { - @SerializedName("address") private String addressB58; - @SerializedName("txhash") private String txid; - @SerializedName("blockhash") private String blockhash; - @SerializedName("category") private String category; + @SerializedName("address") + private String addressB58; + @SerializedName("txhash") + private String txid; + @SerializedName("blockhash") + private String blockhash; + @SerializedName("category") + private String category; private double fee; protected transient double value; private int vout; @@ -39,6 +43,7 @@ public void setCategory(String category) { public void setFee(double fee) { this.fee = fee; } + public double getFee() { return fee; } diff --git a/src/main/java/io/xlite/daemon/app/wallet/WalletHelper.java b/src/main/java/io/xlite/daemon/app/wallet/WalletHelper.java new file mode 100644 index 0000000..656865c --- /dev/null +++ b/src/main/java/io/xlite/daemon/app/wallet/WalletHelper.java @@ -0,0 +1,417 @@ +package io.xlite.daemon.app.wallet; + +import com.google.common.base.Preconditions; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.CoinTickerUtils; +import io.xlite.daemon.app.net.HasFeeParams; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetPeer; +import io.xlite.daemon.app.util.AddressBalance; +import io.xlite.daemon.app.util.CloudTransaction; +import io.xlite.daemon.app.util.Sats; +import io.xlite.daemon.app.util.UTXO; +import org.bitcoinj.core.*; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.wallet.Wallet; + +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class WalletHelper { + private final static LogManager LOGMANAGER = LogManager.getLogManager(); + private final static Logger LOGGER = LOGMANAGER.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private CoinInstance coin; + private NetworkParameters networkParameters; + + public WalletHelper(CoinInstance coinInstance) { + this.coin = coinInstance; + this.networkParameters = coin.getNetworkParameters(); + + } + + public Transaction createRawTransactionWithAllUTXOs(Transaction tx, double amount) { + ArrayList utxos = coinSelector(amount); + + if (utxos == null) { + LOGGER.warning("[wallet-" + coin.getTicker() + "] createRawTransactionWithAllUTXOs: no UTXOs for amount=" + amount); + return null; + } + + return signTransactionWithUtxos(tx, utxos); + } + + private Transaction signTransactionWithUtxos(Transaction tx, ArrayList selectedUtxos) { + try { + if (selectedUtxos == null) { + LOGGER.warning("[wallet-" + coin.getTicker() + "] signTransactionWithUtxos: no UTXOs provided"); + return null; + } + LOGGER.fine("[wallet-" + coin.getTicker() + "] signTransactionWithUtxos: signing " + selectedUtxos.size() + " UTXOs"); + for (UTXO utxo : selectedUtxos) { + if (utxo.isSpent()) + continue; + + org.bitcoinj.core.UTXO bUtxo = utxo.createUTXO(); + AddressBalance addressBalance = coin.getAddressBalance(utxo.getAddress()); + Preconditions.checkNotNull(addressBalance); + + TransactionOutPoint outPoint = new TransactionOutPoint(networkParameters, bUtxo.getIndex(), bUtxo.getHash()); + + tx.addSignedInput(outPoint, bUtxo.getScript(), addressBalance.getPrivateKey().getKey(), Transaction.SigHash.ALL, true); + LOGGER.fine("[wallet-" + coin.getTicker() + "] signed input: txid=" + utxo.getTxid() + " vout=" + utxo.getVout()); + + utxo.setSpent(true); + addressBalance.calculateBalance(); + } + return tx; + } catch (Exception e) { + LOGGER.warning("[wallet-" + coin.getTicker() + "] Error creating transaction" + e.getMessage()); + return null; + } + } + + public Transaction createRawTransactionWithAllUTXOs(ArrayList outputs, double amount) { + Transaction tx = new Transaction(networkParameters); + + for (TransactionOutput output : outputs) { + tx.addOutput(output); + } + + return createRawTransactionWithAllUTXOs(tx, amount); + } + + private ArrayList sortLeastToGreatest() { + ArrayList utxos = new ArrayList<>(); + + for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { + utxos.addAll(addressBalance.getUtxos()); + } + + utxos.sort(Comparator.comparingLong(UTXO::getValue)); + + return utxos; + } + + private ArrayList advancedCoinSorting() { + ArrayList utxos = new ArrayList<>(); + LOGGER.fine("[wallet-" + coin.getTicker() + "] advancedCoinSorting: " + coin.getAddressKeyPairs().size() + " addresses tracked locally"); + for (AddressBalance addressBalance : coin.getAddressKeyPairs()) { + LOGGER.fine("[wallet-" + coin.getTicker() + "] addr=" + addressBalance.getAddress().toBase58() + " utxos=" + addressBalance.getUtxos().size()); + utxos.addAll(addressBalance.getUtxos()); + } + + utxos.sort(Comparator.comparingLong(UTXO::getValue)); + + int sizeOfUtxos = utxos.size(); + + if (sizeOfUtxos <= 1) + return utxos; + + ArrayList utxosLowerHalf = new ArrayList<>(utxos.subList(0, ((sizeOfUtxos + 1) / 2))); + ArrayList utxosGreaterHalf = new ArrayList<>(utxos.subList(((sizeOfUtxos + 1) / 2), sizeOfUtxos)); + + ArrayList res = new ArrayList<>(); + + while (utxosLowerHalf.size() > 0 || utxosGreaterHalf.size() > 0) { + if (utxosLowerHalf.size() > 0) { + res.add(utxosLowerHalf.get(0)); + utxosLowerHalf.remove(0); + } + + if (utxosGreaterHalf.size() > 0) { + res.add(utxosGreaterHalf.get(0)); + utxosGreaterHalf.remove(0); + } + } + + return res; + } + + private ArrayList coinSelector(double amount) { + ArrayList utxos = new ArrayList<>(); + double totalBalance = 0.0; + + ArrayList sorted = advancedCoinSorting(); + LOGGER.fine("[wallet-" + coin.getTicker() + "] coinSelector: requested=" + amount + ", available UTXOs=" + sorted.size()); + for (UTXO utxo : sorted) { + LOGGER.fine("[wallet-" + coin.getTicker() + "] UTXO: txid=" + utxo.getTxid() + " vout=" + utxo.getVout() + " amount=" + utxo.getAmount() + " spent=" + utxo.isSpent()); + } + + for (UTXO utxo : sorted) { + if (totalBalance < amount) { + totalBalance += utxo.getAmount(); + utxos.add(utxo); + } else { + break; + } + } + + if (utxos.size() > 0) { + LOGGER.fine("[wallet-" + coin.getTicker() + "] coinSelector: selected " + utxos.size() + " UTXOs, total=" + totalBalance); + return utxos; + } else { + LOGGER.warning("[wallet-" + coin.getTicker() + "] coinSelector: no UTXOs found (requested=" + amount + ", available in wallet=" + sorted.size() + ")"); + return null; + } + } + + public String formatAmount(double amount) { + DecimalFormat df = new DecimalFormat("#.########"); + return df.format(amount); + } + + public double getTotalBalance() { + double totalBalance = 0.0; + + for (AddressBalance addressBalance : coin.getAddressKeyPairs()) + totalBalance += addressBalance.getBalanceProp(); + + return totalBalance; + } + + public double getSpendBalance(double amount) { + double totalBalance = 0.0; + ArrayList utxos = coinSelector(amount); + + if (utxos == null) { + LOGGER.warning("[wallet-" + coin.getTicker() + "] getSpendBalance: insufficient funds (requested=" + amount + ")"); + return 0.0; + } + for (UTXO utxo : utxos) + totalBalance += utxo.getAmount(); + + LOGGER.fine("[wallet-" + coin.getTicker() + "] getSpendBalance: available=" + totalBalance + " for request=" + amount); + return totalBalance; + } + + public Address getChangeAddress() { + if (coin.getAddressKeyPairs().isEmpty()) { + return null; + } + + return coin.getAddressKeyPairs().get(0).getAddress(); + } + + public AddressBalance generateAddress() { + Wallet wallet = coin.getWallet(); + NetworkParameters params = coin.getNetworkParameters(); + + DeterministicKey key = wallet.freshReceiveKey(); + DumpedPrivateKey privateKey = key.getPrivateKeyEncoded(params); + + LegacyAddress address = LegacyAddress.fromPubKeyHash(params, key.getPubKeyHash()); + + return new AddressBalance(address, privateKey); + } + + public AddressBalance generateFromPrivateKey(String privKey) { + NetworkParameters params = coin.getNetworkParameters(); + + ECKey key = DumpedPrivateKey.fromBase58(params, privKey).getKey(); + DumpedPrivateKey privateKey = key.getPrivateKeyEncoded(params); + + LegacyAddress address = LegacyAddress.fromPubKeyHash(params, key.getPubKeyHash()); + + return new AddressBalance(address, privateKey); + } + + public void addTransactionToWallet(Transaction transaction) { + coin.addCloudTransaction(new CloudTransaction(transaction)); + } + + public double getBlocknetFeeAmount(BlocknetPeer blocknetPeer) { + return blocknetPeer.getxRouterConfiguration().getFeeMap().get("xrSendTransaction"); + } + + public static Transaction createTransactionSimple(CoinTicker coinTicker, String address, double amount) { + return createTransactionSimple(coinTicker, address, amount, false); + } + + public static Transaction createTransactionSimple(CoinTicker coinTicker, String address, double amount, boolean subtractFees) { + CoinInstance coinInstance = CoinInstance.getInstance(coinTicker); + WalletHelper walletHelper = coinInstance.getWalletHelper(); + NetworkParameters params = coinInstance.getNetworkParameters(); + + long feePerByte = getFeePerByte(params); + long minTxFee = getMinTxFee(params); + + long coinUnit = Coin.COIN.value; + + ArrayList allUtxos = walletHelper.sortLeastToGreatest(); + allUtxos.removeIf(UTXO::isSpent); + + if (allUtxos.isEmpty()) { + LOGGER.warning("[wallet-" + CoinTickerUtils.tickerToString(coinTicker) + "] createTransactionSimple: no UTXOs available"); + return null; + } + + double amountNotIncludingFees = amount; + double totalAvailable = 0.0; + for (UTXO utxo : allUtxos) { + totalAvailable += utxo.getAmount(); + } + + if (totalAvailable < amountNotIncludingFees) { + LOGGER.warning("[wallet-" + CoinTickerUtils.tickerToString(coinTicker) + "] createTransactionSimple: insufficient funds (have=" + totalAvailable + ", need=" + amountNotIncludingFees + ")"); + return null; + } + + ArrayList selectedUtxos = new ArrayList<>(); + ArrayList outputs = new ArrayList<>(); + outputs.add(createTransactionOutput(coinTicker, address, amount, params)); + + long estimatedFeeSats = Math.max(feePerByte * (192 + 34), minTxFee); + double estimatedFee = (double) estimatedFeeSats / coinUnit; + + double changeAmt = fundTransaction(allUtxos, selectedUtxos, outputs, amountNotIncludingFees, estimatedFee, subtractFees, feePerByte, minTxFee, coinUnit, totalAvailable); + + if (selectedUtxos.isEmpty()) { + LOGGER.warning("[wallet-" + CoinTickerUtils.tickerToString(coinTicker) + "] createTransactionSimple: failed to select UTXOs"); + return null; + } + + Transaction tx = new Transaction(params); + for (UTXO output : outputs) { + Address addr = LegacyAddress.fromBase58(params, output.getAddress()); + tx.addOutput(Coin.valueOf(Sats.fromWholeCoins(output.getAmount())), addr); + } + + if (changeAmt > 0 && !isDust(changeAmt, params)) { + tx.addOutput(Coin.valueOf(Sats.fromWholeCoins(changeAmt)), walletHelper.getChangeAddress()); + } + + return walletHelper.signTransactionWithUtxos(tx, selectedUtxos); + } + + + private static double fundTransaction(ArrayList allUtxos, ArrayList selectedUtxos, + ArrayList recipientOutputs, double sendAmount, + double initialFee, boolean subtractFees, + long feePerByte, long minTxFee, long coinUnit, double totalAvailable) { + double totalSelected = 0.0; + double fees = initialFee; + double changeAmount = 0.0; + + allUtxos.sort(Comparator.comparingLong(UTXO::getValue)); + + if (allUtxos.size() == 1) { + UTXO utxo = allUtxos.get(0); + selectedUtxos.add(utxo); + totalSelected = utxo.getAmount(); + double required = sendAmount + fees; + if (totalSelected >= required) { + return totalSelected - required; + } + // Shortfall must fail like the multi-input path — returning + // change 0.0 here used to build a deterministically invalid + // transaction (recipient output exceeding the lone input). + throw new RuntimeException("Not enough funds"); + } + + UTXO largestUtxo = allUtxos.get(allUtxos.size() - 1); + changeAmount = largestUtxo.getAmount(); + + for (int i = allUtxos.size() - 1; i >= 0; i--) { + fees = calculateFee(selectedUtxos.size() + 1, recipientOutputs.size() + 1, feePerByte, minTxFee, coinUnit); + double requiredAmount = sendAmount + fees; + UTXO utxo = allUtxos.get(i); + + if (largestUtxo.getAmount() < requiredAmount) { + if (totalSelected < requiredAmount) { + if (totalSelected == 0.0) { + changeAmount = utxo.getAmount(); + } + totalSelected += utxo.getAmount(); + selectedUtxos.add(utxo); + continue; + } else { + break; + } + } else { + if (i == 0 || utxo.getAmount() < requiredAmount) { + UTXO prevUtxo = allUtxos.get(i + 1); + if (totalSelected == 0.0) { + changeAmount = prevUtxo.getAmount(); + } + totalSelected += prevUtxo.getAmount(); + selectedUtxos.add(prevUtxo); + break; + } + } + } + + double totalSendAmount = sendAmount + fees; + if (totalSelected < totalSendAmount) { + if (subtractFees) { + return 0.0; + } + throw new RuntimeException("Not enough funds"); + } + + return totalSelected - totalSendAmount; + } + + private static double calculateFee(int inputCount, int outputCount, long feePerByte, long minTxFee, long coinUnit) { + long feeSats = Math.max(feePerByte * (192 * inputCount + 34 * outputCount), minTxFee); + return (double) feeSats / coinUnit; + } + + private static boolean isDust(double amount, NetworkParameters params) { + return Sats.fromWholeCoins(amount) < params.getMinNonDustOutput().value; + } + + // Package-private for testing. The params must be the coin's own network + // parameters: a null lookup only resolves BTC/testnet version bytes in + // bitcoinj's global registry and rejects every altcoin address. + static UTXO createTransactionOutput(CoinTicker ticker, String address, double amount, NetworkParameters params) { + // Validates the address against this coin's version bytes; throws + // AddressFormatException on mismatch. Do not remove: a null-params + // lookup here rejects every non-BTC address ("No network found"). + LegacyAddress.fromBase58(params, address); + Coin coin = Coin.valueOf(Sats.fromWholeCoins(amount)); + return new UTXO(ticker, address, "", 0, 0, coin.value); + } + + public static void setAsSpent(CoinTicker coinTicker, Transaction transaction, boolean setSpent) { + CoinInstance coinInstance = CoinInstance.getInstance(coinTicker); + + for (TransactionInput input : transaction.getInputs()) { + Sha256Hash txid = input.getOutpoint().getHash(); + long vout = input.getOutpoint().getIndex(); + + if (txid == null) + continue; + + for (AddressBalance addressBalance : coinInstance.getAddressKeyPairs()) { + for (UTXO utxo : addressBalance.getUtxos()) { + + if (Sha256Hash.wrap(utxo.getTxid()).equals(txid) && utxo.getVout() == vout) { + utxo.setSpent(setSpent); + } + } + } + } + } + + private static boolean isP2SHAddress(CoinInstance coin, String address) { + byte[] versionAndDataBytes = Base58.decodeChecked(address); + int version = versionAndDataBytes[0] & 0xFF; + return coin.getNetworkParameters().getP2SHHeader() == version; + } + + public static long getFeePerByte(NetworkParameters params) { + if (params instanceof HasFeeParams) + return ((HasFeeParams) params).getFeePerByte(); + throw new RuntimeException("Failed to get feePerByte for " + params.getClass().getSimpleName()); + } + + public static long getMinTxFee(NetworkParameters params) { + if (params instanceof HasFeeParams) + return ((HasFeeParams) params).getMinTxFee(); + throw new RuntimeException("Failed to get minTxFee for " + params.getClass().getSimpleName()); + } +} diff --git a/src/main/resources/config/netty-reflection.json b/src/main/resources/config/netty-reflection.json new file mode 100644 index 0000000..642b744 --- /dev/null +++ b/src/main/resources/config/netty-reflection.json @@ -0,0 +1,8 @@ +[ + { + "name": "io.netty.channel.socket.nio.NioServerSocketChannel", + "methods": [ + { "name": "", "parameterTypes": [] } + ] + } +] \ No newline at end of file diff --git a/src/main/resources/config/resource-config.json b/src/main/resources/config/resource-config.json new file mode 100644 index 0000000..6086ab5 --- /dev/null +++ b/src/main/resources/config/resource-config.json @@ -0,0 +1,7 @@ +{ + "resources":[ + {"pattern":"mozilla/public-suffix-list.txt"}, + {"pattern":"org/apache/http/client/version.properties"}, + {"pattern":"org/bitcoinj/crypto/mnemonic/wordlist/english.txt"} + ] +} diff --git a/src/main/resources/simplelogger.properties b/src/main/resources/simplelogger.properties new file mode 100644 index 0000000..ecb2732 --- /dev/null +++ b/src/main/resources/simplelogger.properties @@ -0,0 +1,6 @@ +# SLF4J SimpleLogger configuration +# Set bitcoinj logging level to WARN only +org.slf4j.simpleLogger.log.org.bitcoinj=warn + +# Set default logging level to WARN to suppress all other INFO messages +org.slf4j.simpleLogger.defaultLogLevel=warn \ No newline at end of file diff --git a/src/test/java/AddressDiscoveryServiceTest.java b/src/test/java/AddressDiscoveryServiceTest.java new file mode 100644 index 0000000..1f72f9b --- /dev/null +++ b/src/test/java/AddressDiscoveryServiceTest.java @@ -0,0 +1,231 @@ +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.api.http.client.HTTPClient; +import io.xlite.daemon.app.util.AddressDiscoveryService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Tests for AddressDiscoveryService sequential batch scan. + * + * Algorithm: scan batches 0, 1, 2, ... Stop at first empty batch. + * 3 consecutive HTTP failures → abort. No gap limit. + */ +class AddressDiscoveryServiceTest extends TestHelper { + + private CoinInstance coinInstance; + private AddressDiscoveryService discoveryService; + private HTTPClient mockHttpClient; + + @BeforeEach + void setup() { + commonSetup(); + MockitoAnnotations.openMocks(this); + + coinInstance = CoinInstance.getInstance(CoinTicker.BLOCKNET); + assertNotNull(coinInstance); + coinInstance.getConfigHelper().setAddressCount(getAddressCountInitial()); + char[] pw = getPassword().toCharArray(); + try { + assertNull(coinInstance.init(pw, getMnemonic(), false)); + } finally { + Arrays.fill(pw, '\0'); + } + + mockHttpClient = mock(HTTPClient.class); + AddressDiscoveryService.setDiscoveryTimeoutMs(30000); + discoveryService = new AddressDiscoveryService(coinInstance, mockHttpClient); + } + + @AfterAll + static void cleanup() { + commonCleanup(); + } + + private JsonArray buildUtxoResponse(String address) { + JsonArray result = new JsonArray(); + JsonObject utxo = new JsonObject(); + utxo.addProperty("address", address); + utxo.addProperty("txid", "test-txid"); + utxo.addProperty("vout", 0); + utxo.addProperty("confirmations", 10); + utxo.addProperty("value", 1.5); + result.add(utxo); + return result; + } + + /** Batch 0 has UTXOs at index 30, batch 1 empty. Returns 31. */ + @Test + void testDiscovery_FindsUsedAddresses() { + int usedAddressIndex = getAddressCountInitial() + 10; + for (int i = 0; i < 50; i++) coinInstance.generateAddress(false); + + String usedAddress = coinInstance.getAddressKeyPairs().get(usedAddressIndex).getAddress().toBase58(); + + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenAnswer(inv -> { + String[] addrs = inv.getArgument(1); + for (String a : addrs) + if (a.equals(usedAddress)) return buildUtxoResponse(usedAddress); + return new JsonArray(); + }); + + assertEquals(usedAddressIndex + 1, discoveryService.discoverAddressCount()); + verify(mockHttpClient, times(2)).getUtxosUncached(any(), any(String[].class)); + } + + /** Batch 0 empty → wallet unused. 1 HTTP call. */ + @Test + void testDiscovery_EmptyWallet() { + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenReturn(new JsonArray()); + + assertEquals(getAddressCountInitial(), discoveryService.discoverAddressCount()); + verify(mockHttpClient, times(1)).getUtxosUncached(any(), any(String[].class)); + } + + /** 3 consecutive HTTP failures → abort. 3 HTTP calls. */ + @Test + @Timeout(30) + void testDiscovery_ThreeConsecutiveErrorsAbort() { + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenThrow(new RuntimeException("Network error")); + + assertEquals(getAddressCountInitial(), discoveryService.discoverAddressCount()); + verify(mockHttpClient, times(3)).getUtxosUncached(any(), any(String[].class)); + } + + /** Batch 0 fails (1 error), batch 1 has UTXOs, batch 2 empty. Error counter resets on success. */ + @Test + void testDiscovery_SkipsFailedBatch() { + int batchSize = AddressDiscoveryService.getBatchSize(); + for (int i = 0; i < 2 * batchSize; i++) coinInstance.generateAddress(false); + + int usedAddressIndex = getAddressCountInitial() + batchSize + batchSize / 2; // batch 1 + String usedAddress = coinInstance.getAddressKeyPairs().get(usedAddressIndex).getAddress().toBase58(); + + AtomicInteger callCount = new AtomicInteger(0); + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenAnswer(inv -> { + int call = callCount.incrementAndGet(); + if (call == 1) throw new RuntimeException("Transient error"); + String[] addrs = inv.getArgument(1); + for (String a : addrs) + if (a.equals(usedAddress)) return buildUtxoResponse(usedAddress); + return new JsonArray(); + }); + + assertEquals(usedAddressIndex + 1, discoveryService.discoverAddressCount()); + verify(mockHttpClient, times(3)).getUtxosUncached(any(), any(String[].class)); + } + + /** Valid UTXO plus invalid entry. Should still find the address. */ + @Test + void testDiscovery_UtxoParsingErrors() { + for (int i = 0; i < 100; i++) coinInstance.generateAddress(false); + + int usedAddressIndex = getAddressCountInitial() + 10; + String usedAddress = coinInstance.getAddressKeyPairs().get(usedAddressIndex).getAddress().toBase58(); + + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenAnswer(inv -> { + String[] addrs = inv.getArgument(1); + for (String a : addrs) { + if (a.equals(usedAddress)) { + JsonArray result = new JsonArray(); + result.add(buildUtxoResponse(usedAddress).get(0)); + JsonObject invalid = new JsonObject(); + invalid.addProperty("address", "invalid"); + result.add(invalid); + return result; + } + } + return new JsonArray(); + }); + + assertEquals(usedAddressIndex + 1, discoveryService.discoverAddressCount()); + } + + /** + * All NUM_BATCHES have UTXOs. Scans all batches. + * No wallet address pre-generation is needed: discovery derives each batch + * independently via deriveAddressRange(), so the mocked UTXO feed alone + * drives the scan. + */ + @Test + void testDiscovery_AllBatchesFunded() { + int batchSize = AddressDiscoveryService.getBatchSize(); + int numBatches = AddressDiscoveryService.getNumBatches(); + + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenAnswer(inv -> buildUtxoResponse(((String[]) inv.getArgument(1))[0])); + + assertEquals((numBatches - 1) * batchSize + 1, discoveryService.discoverAddressCount()); + verify(mockHttpClient, times(numBatches)).getUtxosUncached(any(), any(String[].class)); + } + + /** Concurrent access — all threads return same result. */ + @Test + void testDiscovery_ConcurrentAccess() throws InterruptedException { + for (int i = 0; i < 100; i++) coinInstance.generateAddress(false); + + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenReturn(new JsonArray()); + + int numThreads = 5; + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + CountDownLatch latch = new CountDownLatch(numThreads); + List results = new ArrayList<>(); + + for (int i = 0; i < numThreads; i++) { + executor.submit(() -> { + try { + int result = discoveryService.discoverAddressCount(); + synchronized (results) { + results.add(result); + } + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + assertEquals(numThreads, results.size()); + for (Integer result : results) + assertEquals(getAddressCountInitial(), result); + } + + /** Batch probes use BATCH_SIZE addresses. */ + @Test + void testDiscovery_BatchSizeRespected() { + int batchSize = AddressDiscoveryService.getBatchSize(); + when(mockHttpClient.getUtxosUncached(any(), any(String[].class))) + .thenAnswer(inv -> { + assertEquals(batchSize, ((String[]) inv.getArgument(1)).length); + return new JsonArray(); + }); + + discoveryService.discoverAddressCount(); + } +} diff --git a/src/test/java/CoinInstanceTest.java b/src/test/java/CoinInstanceTest.java new file mode 100644 index 0000000..2cfc6d9 --- /dev/null +++ b/src/test/java/CoinInstanceTest.java @@ -0,0 +1,171 @@ +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.util.AddressBalance; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for CoinInstance functionality. + * Tests address generation, wallet initialization, and deterministic address creation. + */ +class CoinInstanceTest extends TestHelper { + + @Test + void deterministicAddresses_fromMnemonic() { + for (int runCount = 0; runCount < 3; runCount++) { + CoinInstance coin = CoinInstance.getInstance(CoinTicker.BLOCKNET); + assertNotNull(coin); + coin.getConfigHelper().setAddressCount(getAddressCount()); + char[] pw = getPassword().toCharArray(); + try { + assertNull(coin.init(pw, getMnemonic(), false)); + } finally { + Arrays.fill(pw, '\0'); + } + + List addresses = coin.getAddressKeyPairs(); + ArrayList actual = new ArrayList<>(); + for (AddressBalance address : addresses) { + actual.add(address.getAddress().toBase58()); + } + assertEquals(getAddressCount(), actual.size()); + assertTrue(noDups(actual)); + + List expected = getExpectedAddresses(); + assertTrue(noDups(new ArrayList<>(expected))); + for (int i = 0; i < expected.size(); i++) + assertEquals(expected.get(i), actual.get(i)); + assertEquals(expected.size(), actual.size()); + + coin.deinit(); + clean(); + } + } + + @Test + void deterministicAddresses_generateAddress() { + for (int runCount = 0; runCount < 3; runCount++) { + CoinInstance coin = CoinInstance.getInstance(CoinTicker.BLOCKNET); + assertNotNull(coin); + coin.getConfigHelper().setAddressCount(getAddressCountInitial()); + char[] pw = getPassword().toCharArray(); + try { + assertNull(coin.init(pw, getMnemonic(), false)); + } finally { + Arrays.fill(pw, '\0'); + } + + final int total = getAddressCount() - getAddressCountInitial() - 1; + for (int i = 0; i < total; i++) + coin.generateAddress(false); + coin.generateAddress(true); // last one + + List addresses = coin.getAddressKeyPairs(); + ArrayList actual = new ArrayList<>(); + for (AddressBalance address : addresses) + actual.add(address.getAddress().toBase58()); + assertEquals(getAddressCount(), actual.size()); + assertTrue(noDups(actual)); + + List expected = getExpectedAddresses(); + assertTrue(noDups(new ArrayList<>(expected))); + for (int i = 0; i < expected.size(); i++) + assertEquals(expected.get(i), actual.get(i)); + assertEquals(expected.size(), actual.size()); + + coin.deinit(); + clean(); + } + } + + @Test + void deterministicAddresses_generateForwardAddresses() { + for (int runCount = 0; runCount < 3; runCount++) { + CoinInstance coin = CoinInstance.getInstance(CoinTicker.BLOCKNET); + assertNotNull(coin); + coin.getConfigHelper().setAddressCount(getAddressCountInitial()); + char[] pw = getPassword().toCharArray(); + try { + assertNull(coin.init(pw, getMnemonic(), false)); + } finally { + Arrays.fill(pw, '\0'); + } + + // Reinit which triggers generate forward addresses + coin.getConfigHelper().setAddressCount(getAddressCount()); + char[] pw2 = getPassword().toCharArray(); + try { + assertNull(coin.init(pw2, null, false)); + } finally { + Arrays.fill(pw2, '\0'); + } + + List addresses = coin.getAddressKeyPairs(); + ArrayList actual = new ArrayList<>(); + for (AddressBalance address : addresses) + actual.add(address.getAddress().toBase58()); + assertEquals(getAddressCount(), actual.size()); + assertTrue(noDups(actual)); + + List expected = getExpectedAddresses(); + assertTrue(noDups(new ArrayList<>(expected))); + for (int i = 0; i < expected.size(); i++) + assertEquals(expected.get(i), actual.get(i)); + assertEquals(expected.size(), actual.size()); + + coin.deinit(); + clean(); + } + } + + @Test + void deterministicAddresses_generateForwardAddressesReloadConfig() { + CoinInstance coin = CoinInstance.getInstance(CoinTicker.BLOCKNET); + assertNotNull(coin); + coin.getConfigHelper().setAddressCount(getAddressCountInitial()); + char[] pw = getPassword().toCharArray(); + try { + assertNull(coin.init(pw, getMnemonic(), false)); + } finally { + Arrays.fill(pw, '\0'); + } + + for (int idx = getAddressCountInitial() * 2; idx < getAddressCount(); idx += getAddressCountInitial()) { + // ReloadConfig with new address count triggers generate forward addresses + coin.getConfigHelper().setAddressCount(idx); + coin.getConfigHelper().writeConfig(); + coin.reloadConfig(); + List addresses = coin.getAddressKeyPairs(); + ArrayList actual = new ArrayList<>(); + for (AddressBalance address : addresses) + actual.add(address.getAddress().toBase58()); + assertEquals(idx, actual.size()); + assertTrue(noDups(actual)); + + List expected = getExpectedAddresses(); + assertTrue(noDups(new ArrayList<>(expected))); + for (int i = 0; i < idx; i++) + assertEquals(expected.get(i), actual.get(i)); + } + + coin.deinit(); + clean(); + } + + @BeforeEach + void setup() { + commonSetup(); + } + + @AfterAll + static void cleanup() { + commonCleanup(); + } +} diff --git a/src/test/java/ConfigHelperTest.java b/src/test/java/ConfigHelperTest.java new file mode 100644 index 0000000..b37a8bc --- /dev/null +++ b/src/test/java/ConfigHelperTest.java @@ -0,0 +1,441 @@ +import io.xlite.daemon.app.util.ConfigHelper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for ConfigHelper functionality. + * Tests configuration file operations, address count management, and directory handling. + */ +class ConfigHelperTest extends TestHelper { + + private ConfigHelper configHelper; + + @BeforeEach + void setup() { + commonSetup(); + configHelper = new ConfigHelper("test"); + } + + @AfterAll + static void cleanup() { + commonCleanup(); + } + + @Test + void testConfigFileCreation() { + // The config file should be created when ConfigHelper is instantiated + // We can verify this by checking if the config directory exists and has files + String localDataDir = ConfigHelper.getLocalDataDirectory(); + File settingsDir = new File(localDataDir, "settings"); + assertTrue(settingsDir.exists()); + + // Check if config file exists by trying to read it + File configFile = new File(settingsDir, "config-test.json"); + assertTrue(configFile.exists()); + } + + @Test + void testDefaultConfigurationValues() { + assertEquals(0L, configHelper.getFeePerByte()); + assertEquals(0L, configHelper.getMinTxFee()); + assertFalse(configHelper.isRpcEnabled()); + assertEquals("", configHelper.getRpcUsername()); + assertEquals("", configHelper.getRpcPassword()); + assertEquals(-1000, configHelper.getRpcPort()); + assertEquals(0, configHelper.getAddressCount()); + } + + @Test + void testSetAndGetFeePerByte() { + long newFeePerByte = 60L; + configHelper.setFeePerByte(newFeePerByte); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertEquals(newFeePerByte, reloadedConfig.getFeePerByte()); + } + + @Test + void testSetAndGetMinTxFee() { + long newMinTxFee = 12000L; + configHelper.setMinTxFee(newMinTxFee); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertEquals(newMinTxFee, reloadedConfig.getMinTxFee()); + } + + @Test + void testSetAndGetRpcEnabled() { + configHelper.setRpcEnabled(true); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertTrue(reloadedConfig.isRpcEnabled()); + } + + @Test + void testSetAndGetRpcCredentials() { + String username = "testuser"; + String password = "testpass"; + + configHelper.setRpcUsername(username); + configHelper.setRpcPassword(password); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertEquals(username, reloadedConfig.getRpcUsername()); + assertEquals(password, reloadedConfig.getRpcPassword()); + } + + @Test + void testSetAndGetRpcPort() { + int port = 8080; + configHelper.setRpcPort(port); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertEquals(port, reloadedConfig.getRpcPort()); + } + + @Test + void testSetAndGetAddressCount() { + int addressCount = 50; + configHelper.setAddressCount(addressCount); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertEquals(addressCount, reloadedConfig.getAddressCount()); + } + + @Test + void testValidAuth() { + assertFalse(configHelper.validAuth()); + + configHelper.setRpcUsername("user"); + configHelper.setRpcPassword("pass"); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertTrue(reloadedConfig.validAuth()); + } + + @Test + void testInvalidAuthWithEmptyUsername() { + configHelper.setRpcUsername(""); + configHelper.setRpcPassword("pass"); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertFalse(reloadedConfig.validAuth()); + } + + @Test + void testInvalidAuthWithEmptyPassword() { + configHelper.setRpcUsername("user"); + configHelper.setRpcPassword(""); + configHelper.writeConfig(); + + ConfigHelper reloadedConfig = new ConfigHelper("test"); + assertFalse(reloadedConfig.validAuth()); + } + + @Test + void testConfigDirectoryCreation() { + String localDataDir = ConfigHelper.getLocalDataDirectory(); + assertNotNull(localDataDir); + + File settingsDir = new File(localDataDir, "settings"); + assertTrue(settingsDir.exists() || settingsDir.mkdirs()); + } + + @Test + void testConfigFilePersistence() { + configHelper.setFeePerByte(60L); + configHelper.setMinTxFee(12000L); + configHelper.setRpcEnabled(true); + configHelper.setAddressCount(100); + configHelper.writeConfig(); + + ConfigHelper newConfig = new ConfigHelper("test"); + assertEquals(60L, newConfig.getFeePerByte()); + assertEquals(12000L, newConfig.getMinTxFee()); + assertTrue(newConfig.isRpcEnabled()); + assertEquals(100, newConfig.getAddressCount()); + } + + @Test + void testLoadConfigFromFile() { + String configContent = "{\n" + + " \"feeperbyte\": 60,\n" + + " \"mintxfee\": 12000,\n" + + " \"rpcEnabled\": true,\n" + + " \"rpcUsername\": \"testuser\",\n" + + " \"rpcPassword\": \"testpass\",\n" + + " \"rpcPort\": 9000,\n" + + " \"addressCount\": 25\n" + + "}"; + + String localDataDir = ConfigHelper.getLocalDataDirectory(); + File settingsDir = new File(localDataDir, "settings"); + File configFile = new File(settingsDir, "config-test.json"); + + try { + Files.write(configFile.toPath(), configContent.getBytes()); + } catch (IOException e) { + fail("Failed to write config file", e); + } + + ConfigHelper loadedConfig = new ConfigHelper("test"); + + assertEquals(60L, loadedConfig.getFeePerByte()); + assertEquals(12000L, loadedConfig.getMinTxFee()); + assertTrue(loadedConfig.isRpcEnabled()); + assertEquals("testuser", loadedConfig.getRpcUsername()); + assertEquals("testpass", loadedConfig.getRpcPassword()); + assertEquals(9000, loadedConfig.getRpcPort()); + assertEquals(25, loadedConfig.getAddressCount()); + } + + @Test + void testMigration_movesLegacyCloudChainsToXliteDaemon(@TempDir Path tmp) throws IOException { + // Arrange: create legacy CloudChains directory with a settings file + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + ConfigHelper.CONFIG_DIR = tmp.toString(); + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + org.junit.jupiter.api.Assertions.assertFalse(Files.exists(newDir), "new dir must not exist before migration"); + Files.createDirectories(oldDir.resolve("settings")); + Path legacyFile = oldDir.resolve("settings").resolve("config-legacy.json"); + String content = "{\"feeperbyte\":1}"; + Files.write(legacyFile, content.getBytes(StandardCharsets.UTF_8)); + + // Act: trigger migration via getLocalDataDirectory + String returned = ConfigHelper.getLocalDataDirectory(); + org.junit.jupiter.api.Assertions.assertTrue(returned.endsWith("xlite-daemon" + File.separator)); + + // Assert: legacy content migrated to new location + Path migratedFile = newDir.resolve("settings").resolve("config-legacy.json"); + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(newDir), "xlite-daemon dir must exist after migration"); + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(migratedFile), "legacy file must be migrated to new dir"); + assertEquals(content, new String(Files.readAllBytes(migratedFile), StandardCharsets.UTF_8)); + org.junit.jupiter.api.Assertions.assertFalse(Files.exists(oldDir), "old CloudChains dir must be moved (not remain)"); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + // Cleanup temp-data dirs created via CONFIG_DIR override so commonCleanup().deleteDir + // on the default "." path still succeeds; remove any leftover tmp subdirs eagerly. + // JUnit @TempDir will delete tmp itself, but ensure CONFIG_DIR restored. + } + } + + @Test + void testMigration_doesNotOverwriteExistingNewDir(@TempDir Path tmp) throws IOException { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + ConfigHelper.CONFIG_DIR = tmp.toString(); + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + Files.createDirectories(oldDir.resolve("settings")); + Files.createDirectories(newDir.resolve("settings")); + Path oldFile = oldDir.resolve("settings").resolve("config-old.json"); + Path newFile = newDir.resolve("settings").resolve("config-new.json"); + Files.write(oldFile, "{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + Files.write(newFile, "{\"b\":2}".getBytes(StandardCharsets.UTF_8)); + + ConfigHelper.getLocalDataDirectory(); + + // Fail-closed merge: disjoint legacy files are merged in, never + // orphaned, and the legacy dir is archived (not left live). + Path mergedFile = newDir.resolve("settings").resolve("config-old.json"); + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(mergedFile), + "disjoint legacy file must be merged into new dir"); + assertEquals("{\"a\":1}", new String(Files.readAllBytes(mergedFile), StandardCharsets.UTF_8)); + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(newFile), "existing new file must be kept"); + org.junit.jupiter.api.Assertions.assertFalse(Files.exists(oldDir), "legacy dir must be archived, not left live"); + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(tmp.resolve("CloudChains.bak")), + "legacy dir must be preserved as backup"); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } + + @Test + void testMigration_resumesPartialNewDir(@TempDir Path tmp) throws IOException { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + ConfigHelper.CONFIG_DIR = tmp.toString(); + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + Files.createDirectories(oldDir.resolve("settings")); + Files.write(oldDir.resolve("settings").resolve("config-a.json"), + "{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + Files.write(oldDir.resolve("settings").resolve("config-b.json"), + "{\"b\":2}".getBytes(StandardCharsets.UTF_8)); + // Simulate an interrupted migration: only one file made it over. + Files.createDirectories(newDir.resolve("settings")); + Files.write(newDir.resolve("settings").resolve("config-a.json"), + "{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + + ConfigHelper.getLocalDataDirectory(); + + // Must complete the migration, never boot on the partial dir. + Path resumed = newDir.resolve("settings").resolve("config-b.json"); + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(resumed), + "interrupted migration must resume and complete"); + assertEquals("{\"b\":2}", new String(Files.readAllBytes(resumed), StandardCharsets.UTF_8)); + org.junit.jupiter.api.Assertions.assertFalse(Files.exists(oldDir), "legacy dir must be archived after resume"); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } + + @Test + void testMigration_conflictingFilesThrowFailClosed(@TempDir Path tmp) throws IOException { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + ConfigHelper.CONFIG_DIR = tmp.toString(); + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + Files.createDirectories(oldDir.resolve("settings")); + Files.createDirectories(newDir.resolve("settings")); + // Same relative path, different bytes: no safe automatic choice + // (either side could be the real wallet), so refuse to boot. + Files.write(oldDir.resolve("settings").resolve("config-c.json"), + "{\"c\":1}".getBytes(StandardCharsets.UTF_8)); + Files.write(newDir.resolve("settings").resolve("config-c.json"), + "{\"c\":2}".getBytes(StandardCharsets.UTF_8)); + // Plus a disjoint file: refusal must be side-effect-free, so even + // mergeable files must NOT be copied before the throw. + Files.write(oldDir.resolve("settings").resolve("config-d.json"), + "{\"d\":1}".getBytes(StandardCharsets.UTF_8)); + + assertThrows(IllegalStateException.class, ConfigHelper::getLocalDataDirectory); + org.junit.jupiter.api.Assertions.assertFalse( + Files.exists(newDir.resolve("settings").resolve("config-d.json")), + "refusal must leave the live dir untouched"); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } + + @Test + void testMigration_uncreatableDataDirThrowsFailClosed(@TempDir Path tmp) throws IOException { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + // Base is a regular file: no directory can ever be created beneath it. + Path blocker = tmp.resolve("blocker"); + Files.write(blocker, "x".getBytes(StandardCharsets.UTF_8)); + ConfigHelper.CONFIG_DIR = blocker.toString(); + + // Must fail loudly, never return a bogus path and boot empty. + assertThrows(IllegalStateException.class, ConfigHelper::getLocalDataDirectory); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } + + @Test + void testMigration_blankBaseDirThrowsFailClosed(@TempDir Path tmp) { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + // Whitespace-only override must fail fast. (A null CONFIG_DIR is + // treated exactly like the default empty string and resolves via + // App.getUserConfigDir, so it cannot be asserted hermetically.) + ConfigHelper.CONFIG_DIR = " "; + + assertThrows(IllegalStateException.class, ConfigHelper::getLocalDataDirectory); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } + + @Test + void testMigration_existingBackupRefusesToBoot(@TempDir Path tmp) throws IOException { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + ConfigHelper.CONFIG_DIR = tmp.toString(); + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + Files.createDirectories(oldDir.resolve("settings")); + Files.createDirectories(newDir.resolve("settings")); + Files.write(oldDir.resolve("settings").resolve("config-old.json"), + "{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + Files.write(newDir.resolve("settings").resolve("config-new.json"), + "{\"b\":2}".getBytes(StandardCharsets.UTF_8)); + // A previous archival already parked a backup here: archiving + // again would destroy rollback data, so refuse instead. + Files.createDirectories(tmp.resolve("CloudChains.bak")); + + assertThrows(IllegalStateException.class, ConfigHelper::getLocalDataDirectory); + // Nothing was archived over or deleted, and the refused merge + // copied nothing: the legacy source is intact for manual recovery. + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(oldDir)); + org.junit.jupiter.api.Assertions.assertFalse( + Files.exists(newDir.resolve("settings").resolve("config-old.json")), + "refusal must leave the live dir untouched"); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } + + @Test + void testMigration_stagingLeftoverIsRedone(@TempDir Path tmp) throws IOException { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + ConfigHelper.CONFIG_DIR = tmp.toString(); + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + Path staging = tmp.resolve("xlite-daemon.migrating"); + Files.createDirectories(oldDir.resolve("settings")); + Files.write(oldDir.resolve("settings").resolve("config-a.json"), + "{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + // Leftover of an interrupted copy-verify-rename: stale junk only. + Files.createDirectories(staging); + Files.write(staging.resolve("junk.tmp"), "stale".getBytes(StandardCharsets.UTF_8)); + + ConfigHelper.getLocalDataDirectory(); + + // Redone from the intact source: junk gone, content verified. + org.junit.jupiter.api.Assertions.assertFalse(Files.exists(staging.resolve("junk.tmp")), + "stale staging content must not leak into the data dir"); + Path migrated = newDir.resolve("settings").resolve("config-a.json"); + org.junit.jupiter.api.Assertions.assertTrue(Files.exists(migrated)); + assertEquals("{\"a\":1}", new String(Files.readAllBytes(migrated), StandardCharsets.UTF_8)); + org.junit.jupiter.api.Assertions.assertFalse(Files.exists(oldDir), "legacy dir must be archived after redo"); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } + + @Test + void testSettingsAsFileFailsClosed(@TempDir Path tmp) throws IOException { + String originalConfigDir = ConfigHelper.CONFIG_DIR; + try { + ConfigHelper.CONFIG_DIR = tmp.toString(); + // Poison the settings path with a regular file: every config + // write below it would fail, so construction must refuse. + Path dataDir = tmp.resolve("xlite-daemon"); + Files.createDirectories(dataDir); + Files.write(dataDir.resolve("settings"), "not-a-dir".getBytes(StandardCharsets.UTF_8)); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new ConfigHelper("test")); + org.junit.jupiter.api.Assertions.assertTrue(e.getMessage().contains("data directory unusable"), + "message must name the failure, got: " + e.getMessage()); + } finally { + ConfigHelper.CONFIG_DIR = originalConfigDir; + } + } +} \ No newline at end of file diff --git a/src/test/java/KeyHandlerTest.java b/src/test/java/KeyHandlerTest.java new file mode 100644 index 0000000..24f20e6 --- /dev/null +++ b/src/test/java/KeyHandlerTest.java @@ -0,0 +1,396 @@ +import io.xlite.daemon.app.crypto.KeyHandler; +import io.xlite.daemon.app.util.ConfigHelper; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for KeyHandler. + * + *

Key design choices: + *

    + *
  • {@code @TempDir} is declared as a static field so the same directory is + * reused across all tests in this class and cleaned up automatically by JUnit 5. + *
  • Passphrases are {@code char[]} throughout, matching the production API. + * Each test allocates a fresh array and zero-fills it in a {@code finally} block. + *
  • The legacy wallet helper uses {@code java.util.Base64} (standard library) + * instead of a third-party encoder, removing an accidental cross-library coupling. + *
+ * + *

Thread safety: {@code ConfigHelper.CONFIG_DIR} is a mutable static field. + * Do not run these tests with JUnit 5 parallel execution enabled unless you isolate + * the static state per worker. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class KeyHandlerTest { + + // ------------------------------------------------------------------------- + // Test fixtures + // ------------------------------------------------------------------------- + + private static final String TEST_PASSPHRASE = "testPassphrase123!"; + private static final String TEST_MNEMONIC = + "one two three cake neutral benefit quick hip level mother fine burst"; + private static final List TEST_MNEMONIC_LIST = + Arrays.asList(TEST_MNEMONIC.split(" ")); + + // Expected scores for calculatePasswordStrength — named so assertion failures + // are self-documenting. + private static final int SCORE_TOO_SHORT = 0; // < 8 chars + private static final int SCORE_EIGHT_LOWERCASE_ONLY = 3; // 1 (len 8-9) + 2 (lower) + private static final int SCORE_TEN_LOWER_DIGIT = 6; // 2 (len 10+) + 2 (lower) + 2 (digit) + private static final int SCORE_ALL_CRITERIA = 10; // 2+2+2+2+2 + + /** Shared temp directory — JUnit 5 removes it and all contents after the test class. */ + @TempDir + static Path tempDir; + + private File testKeyFile; + private File testBackupDir; + + @BeforeEach + void setUp() { + // Point ConfigHelper at the isolated temp directory for every test. + ConfigHelper.CONFIG_DIR = tempDir.toString(); + testKeyFile = tempDir.resolve("xlite-daemon").resolve("key.dat").toFile(); + testBackupDir = tempDir.resolve("xlite-daemon").resolve("backups").toFile(); + } + + // ========================================================================= + // calculatePasswordStrength + // ========================================================================= + + @Test + @Order(1) + void testPasswordStrengthTooShort() { + assertEquals(SCORE_TOO_SHORT, KeyHandler.calculatePasswordStrength("short".toCharArray())); + } + + @Test + @Order(2) + void testPasswordStrengthEightCharLowercaseOnly() { + // 1 (8-9 chars) + 2 (lowercase) = 3 + assertEquals(SCORE_EIGHT_LOWERCASE_ONLY, + KeyHandler.calculatePasswordStrength("eightchr".toCharArray())); + } + + @Test + @Order(3) + void testPasswordStrengthNineCharSameAsEight() { + // Both 8 and 9 characters should yield the same length bonus (+1). + assertEquals( + KeyHandler.calculatePasswordStrength("eightchr".toCharArray()), + KeyHandler.calculatePasswordStrength("ninechars".toCharArray()), + "8-char and 9-char passwords must receive the same length bonus" + ); + } + + @Test + @Order(4) + void testPasswordStrengthTenPlusWithDigitAndLower() { + // 2 (10+ chars) + 2 (digit) + 2 (lowercase) = 6 + assertEquals(SCORE_TEN_LOWER_DIGIT, + KeyHandler.calculatePasswordStrength("tenchars12".toCharArray())); + } + + @Test + @Order(5) + void testPasswordStrengthAllCriteria() { + assertEquals(SCORE_ALL_CRITERIA, + KeyHandler.calculatePasswordStrength("StrongPass123!".toCharArray())); + } + + // ========================================================================= + // getMnemonicFromString + // ========================================================================= + + @Test + @Order(6) + void testMnemonicFromStringNormal() { + List result = KeyHandler.getMnemonicFromString(TEST_MNEMONIC); + assertEquals(12, result.size()); + assertEquals("one", result.get(0)); + assertEquals("burst", result.get(11)); + } + + @Test + @Order(7) + void testMnemonicFromStringExtraWhitespace() { + // Leading, trailing, and double spaces must all be collapsed. + List result = KeyHandler.getMnemonicFromString(" one two three "); + assertEquals(3, result.size()); + assertEquals("one", result.get(0)); + } + + // ========================================================================= + // mnemonicToEntropy + // ========================================================================= + + @Test + @Order(8) + void testMnemonicToEntropy() { + byte[] entropy = KeyHandler.mnemonicToEntropy(TEST_MNEMONIC_LIST); + assertNotNull(entropy, "Entropy must not be null for a valid mnemonic"); + assertEquals(16, entropy.length, + "A 12-word BIP39 mnemonic yields 16 bytes of entropy"); + } + + // ========================================================================= + // importFromMnemonic + key file structure + // ========================================================================= + + @Test + @Order(9) + void testImportFromMnemonicCreatesFile() { + char[] passphrase = TEST_PASSPHRASE.toCharArray(); + try { + boolean success = KeyHandler.importFromMnemonic(TEST_MNEMONIC_LIST, passphrase); + assertTrue(success, "Import should succeed for a valid mnemonic"); + assertTrue(testKeyFile.exists(), "Wallet file must be created on disk"); + } finally { + Arrays.fill(passphrase, '\0'); + } + } + + @Test + @Order(10) + void testImportedWalletFileHasCorrectStructure() throws IOException { + char[] passphrase = TEST_PASSPHRASE.toCharArray(); + try { + KeyHandler.importFromMnemonic(TEST_MNEMONIC_LIST, passphrase); + } finally { + Arrays.fill(passphrase, '\0'); + } + + String[] lines = readKeyFileLines(); + assertEquals(4, lines.length, + "V2 wallet file must have exactly 4 lines: VERSION, salt, IV, ciphertext"); + assertEquals("VERSION:2", lines[0], + "First line must be the version header"); + assertFalse(lines[1].isEmpty(), "Salt line must not be empty"); + assertFalse(lines[2].isEmpty(), "IV line must not be empty"); + assertFalse(lines[3].isEmpty(), "Ciphertext line must not be empty"); + } + + // ========================================================================= + // getBaseSeed — round-trip and error paths + // ========================================================================= + + @Test + @Order(11) + void testGetBaseSeedRoundTrip() { + char[] importPass = TEST_PASSPHRASE.toCharArray(); + char[] readPass = TEST_PASSPHRASE.toCharArray(); + try { + assertTrue(KeyHandler.importFromMnemonic(TEST_MNEMONIC_LIST, importPass)); + + List seed = KeyHandler.getBaseSeed(readPass); + assertNotNull(seed, "Seed must be recoverable with the correct passphrase"); + assertEquals(12, seed.size()); + assertEquals("one", seed.get(0)); + assertEquals("burst", seed.get(11)); + } finally { + Arrays.fill(importPass, '\0'); + Arrays.fill(readPass, '\0'); + } + } + + @Test + @Order(12) + void testGetBaseSeedWrongPassphraseReturnsNull() { + char[] importPass = TEST_PASSPHRASE.toCharArray(); + char[] wrongPass = "wrongPassword".toCharArray(); + try { + KeyHandler.importFromMnemonic(TEST_MNEMONIC_LIST, importPass); + assertTrue(testKeyFile.exists(), + "Wallet file must exist before testing wrong passphrase"); + + List seed = KeyHandler.getBaseSeed(wrongPass); + assertNull(seed, "Wrong passphrase must return null"); + + // Verify the wallet file was not damaged by the failed attempt. + assertTrue(testKeyFile.exists(), + "Wallet file must still exist after a failed decryption"); + assertEquals(4, readKeyFileLines().length, + "Wallet file must retain its valid 4-line structure"); + } catch (IOException e) { + fail("Unexpected IOException reading wallet file: " + e.getMessage()); + } finally { + Arrays.fill(importPass, '\0'); + Arrays.fill(wrongPass, '\0'); + } + } + + // ========================================================================= + // Backup on re-import + // ========================================================================= + + @Test + @Order(13) + void testReimportCreatesBackup() { + char[] passphrase = TEST_PASSPHRASE.toCharArray(); + try { + // First import — no backup should exist yet. + KeyHandler.importFromMnemonic(TEST_MNEMONIC_LIST, passphrase); + passphrase = TEST_PASSPHRASE.toCharArray(); // re-allocate after first use + + // Second import — the previous wallet must be backed up. + KeyHandler.importFromMnemonic(TEST_MNEMONIC_LIST, passphrase); + + assertTrue(testBackupDir.exists(), "Backups directory must be created"); + File[] backups = testBackupDir.listFiles(); + assertNotNull(backups); + assertTrue(backups.length > 0, + "At least one backup file must exist after a second import"); + } finally { + Arrays.fill(passphrase, '\0'); + } + } + + // ========================================================================= + // Legacy wallet migration + // ========================================================================= + + @Test + @Order(14) + void testLegacyWalletIsReadable() throws IOException { + createLegacyWalletFile(); + + char[] passphrase = TEST_PASSPHRASE.toCharArray(); + try { + List seed = KeyHandler.getBaseSeed(passphrase); + assertNotNull(seed, "Legacy wallet must be decryptable with the correct passphrase"); + assertEquals(12, seed.size()); + } finally { + Arrays.fill(passphrase, '\0'); + } + } + + @Test + @Order(15) + void testLegacyWalletIsMigratedToV2() throws IOException { + createLegacyWalletFile(); + + char[] passphrase = TEST_PASSPHRASE.toCharArray(); + try { + KeyHandler.getBaseSeed(passphrase); + } finally { + Arrays.fill(passphrase, '\0'); + } + + // After migration the key file must be V2 format. + String[] lines = readKeyFileLines(); + assertEquals("VERSION:2", lines[0], + "Migrated wallet must carry the V2 version header"); + assertEquals(4, lines.length, + "Migrated wallet must have the 4-line V2 structure"); + } + + @Test + @Order(16) + void testLegacyMigrationCreatesLegacyBackup() throws IOException { + createLegacyWalletFile(); + + char[] passphrase = TEST_PASSPHRASE.toCharArray(); + try { + List seed = KeyHandler.getBaseSeed(passphrase); + assertNotNull(seed); + assertEquals(12, seed.size()); + } finally { + Arrays.fill(passphrase, '\0'); + } + + // migrateToNewFormat places the legacy backup directly in getLocalDataDirectory(), + // not in the backups/ subdirectory, so it is easy to locate manually. + File dataDir = new File(ConfigHelper.getLocalDataDirectory()); + File[] legacyBackups = dataDir.listFiles( + (dir, name) -> name.startsWith("key-backup-legacy-")); + assertNotNull(legacyBackups); + assertTrue(legacyBackups.length > 0, + "A timestamped legacy backup must be created during migration"); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Write a valid V1 (AES-ECB / PBKDF2-SHA-1 / 16 384 iterations) wallet file + * to the path that KeyHandler will read. + * + *

Uses only {@link java.util.Base64} (standard library) — no third-party encoder. + */ + private void createLegacyWalletFile() throws IOException { + testKeyFile.getParentFile().mkdirs(); + char[] hashedPass = sha256Hex(TEST_PASSPHRASE); + try { + SecureRandom rng = new SecureRandom(); + byte[] salt = new byte[20]; + rng.nextBytes(salt); + + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + PBEKeySpec spec = new PBEKeySpec(hashedPass, salt, 16_384, 256); + SecretKey tmp = skf.generateSecret(spec); + SecretKey key = new SecretKeySpec(tmp.getEncoded(), "AES"); + spec.clearPassword(); + + Cipher cipher = Cipher.getInstance("AES"); // defaults to ECB — intentional for V1 + cipher.init(Cipher.ENCRYPT_MODE, key); + byte[] encrypted = cipher.doFinal(TEST_MNEMONIC.getBytes(StandardCharsets.UTF_8)); + + // V1 format: two lines — Base64(salt) then Base64(ciphertext). + Base64.Encoder encoder = Base64.getEncoder(); + try (BufferedWriter writer = new BufferedWriter(new FileWriter(testKeyFile))) { + writer.write(encoder.encodeToString(salt)); + writer.newLine(); + writer.write(encoder.encodeToString(encrypted)); + writer.newLine(); + } + } catch (Exception e) { + throw new IOException("Failed to create legacy wallet file for test", e); + } finally { + Arrays.fill(hashedPass, '\0'); + } + } + + /** Read the wallet file and return its lines, or an empty array if absent. */ + private String[] readKeyFileLines() throws IOException { + if (!testKeyFile.exists()) return new String[0]; + try (BufferedReader reader = new BufferedReader(new FileReader(testKeyFile))) { + return reader.lines().toArray(String[]::new); + } + } + + /** + * Replicate the legacy LoginUtils.loginToEntropy() behavior: + * SHA-256 hash the input and return the hex digest as a char[]. + */ + private static char[] sha256Hex(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString().toCharArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to compute SHA-256 for test", e); + } + } +} \ No newline at end of file diff --git a/src/test/java/TestHelper.java b/src/test/java/TestHelper.java new file mode 100644 index 0000000..1dea62c --- /dev/null +++ b/src/test/java/TestHelper.java @@ -0,0 +1,173 @@ +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.util.ConfigHelper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** + * Test helper class providing shared utilities for all test files. + * This avoids code duplication across test files. + */ +public class TestHelper { + + private static final Gson GSON = new GsonBuilder().create(); + protected static JsonObject testConfig; + + static { + try { + loadTestConfig(); + } catch (IOException e) { + throw new RuntimeException("Failed to load test configuration", e); + } + } + + private static void loadTestConfig() throws IOException { + try (InputStream is = TestHelper.class.getClassLoader().getResourceAsStream("test_config.json")) { + if (is == null) { + throw new IOException("Could not find test_config.json in classpath"); + } + InputStreamReader reader = new InputStreamReader(is); + testConfig = GSON.fromJson(reader, JsonObject.class); + } + } + + // Test parameters - shared across all test files + protected static final int ADDRESS_COUNT_INITIAL = getAddressCountInitial(); + protected static final int ADDRESS_COUNT = getAddressCount(); + protected static final String MNEMONIC = getMnemonic(); + protected static final String PASSWORD = getPassword(); + protected static final String COIN_TICKER = "LITECOIN"; + + protected static int getAddressCount() { + return testConfig.getAsJsonObject("test_parameters").get("address_count").getAsInt(); + } + + protected static int getAddressCountInitial() { + return testConfig.getAsJsonObject("test_parameters").get("address_count_initial").getAsInt(); + } + + protected static String getPassword() { + return testConfig.getAsJsonObject("test_parameters").get("password").getAsString(); + } + + protected static String getMnemonic() { + return testConfig.getAsJsonObject("test_parameters").get("mnemonic").getAsString(); + } + + protected static ArrayList getExpectedAddresses() { + List list = GSON.fromJson(testConfig.get("expected_addresses"), new TypeToken>(){ + }.getType()); + return new ArrayList<>(list); + } + + /** + * Common setup method for all test files. + * Disables address discovery during tests to prevent interference. + */ + @BeforeEach + public void commonSetup() { + ConfigHelper.CONFIG_DIR = "."; + clean(); + // Disable address discovery during tests to prevent interference with deterministic address generation + CoinInstance.setAddressDiscoveryEnabled(false); + // Ensure coin configs are available — simple in-memory set, no filesystem + if (!io.xlite.daemon.app.coinconfig.CoinConfigRegistry.isLoaded()) { + java.util.Map cfgs = new java.util.LinkedHashMap<>(); + java.util.Map ltc = new java.util.LinkedHashMap<>(); + ltc.put("AddressPrefix", "48"); ltc.put("ScriptPrefix", "50"); ltc.put("SecretPrefix", "176"); + ltc.put("COIN", "100000000"); ltc.put("FeePerByte", "10"); ltc.put("MinTxFee", "5000"); + ltc.put("Port", "9332"); ltc.put("DustAmount", "0"); ltc.put("Title", "Litecoin"); + cfgs.put("LTC", new io.xlite.daemon.app.coinconfig.CoinConfig("LTC", "Litecoin", "litecoin--v0.21.1", ltc)); + java.util.Map block = new java.util.LinkedHashMap<>(); + block.put("AddressPrefix", "26"); block.put("ScriptPrefix", "28"); block.put("SecretPrefix", "154"); + block.put("COIN", "100000000"); block.put("FeePerByte", "20"); block.put("MinTxFee", "10000"); + block.put("Port", "41414"); block.put("DustAmount", "0"); block.put("Title", "Blocknet"); + cfgs.put("BLOCK", new io.xlite.daemon.app.coinconfig.CoinConfig("BLOCK", "Blocknet", "blocknet--v4.2.0", block)); + io.xlite.daemon.app.coinconfig.CoinConfigRegistry.loadForTest(cfgs); + } + } + + /** + * Common cleanup method for all test files. Pins CONFIG_DIR to the + * workdir sandbox: without this, cleanup after a test that restored the + * default empty CONFIG_DIR would resolve (and delete!) the real home + * data directory, depending on JUnit method order. + */ + @AfterAll + public static void commonCleanup() { + ConfigHelper.CONFIG_DIR = "."; + clean(); + } + + /** + * Cleans up test data and resets CoinInstance state. + */ + protected static void clean() { + CoinInstance.getCoinInstances().clear(); + assertTrue(deleteDir(new File(ConfigHelper.getLocalDataDirectory()))); + String base = ConfigHelper.CONFIG_DIR == null || ConfigHelper.CONFIG_DIR.trim().isEmpty() + ? io.xlite.daemon.app.App.getUserConfigDir() + : ConfigHelper.CONFIG_DIR; + File migrationLock = new File(base, ConfigHelper.MIGRATION_LOCK_FILE); + if (migrationLock.exists()) { + assertTrue(migrationLock.delete()); + } + File staging = new File(base, ConfigHelper.MIGRATION_STAGING_NAME); + if (staging.exists()) { + assertTrue(deleteDir(staging)); + } + } + + /** + * Deletes a directory and all its contents recursively. + * @param path The directory to delete + * @return true if deletion was successful + */ + protected static boolean deleteDir(File path) { + if (!path.exists()) + return true; + for (File subFile : Objects.requireNonNull(path.listFiles())) { + if (subFile.isDirectory()) { + deleteDir(subFile); + } else { + if (!subFile.delete()) + return false; + } + } + return path.delete(); + } + + /** + * Returns true if there's no duplicates in the list. + * @param list The list to check for duplicates + * @return True if no duplicates + */ + protected static boolean noDups(ArrayList list) { + HashSet set = new HashSet<>(list); + return list.size() == set.size(); + } + + /** + * Asserts that a condition is true. + * This is a simple assertion method to avoid importing JUnit in the helper. + * @param condition The condition to assert + * @throws AssertionError if the condition is false + */ + protected static void assertTrue(boolean condition) { + if (!condition) { + throw new AssertionError("Expected true but was false"); + } + } +} \ No newline at end of file diff --git a/src/test/java/TestWallet.java b/src/test/java/TestWallet.java deleted file mode 100644 index 885e52a..0000000 --- a/src/test/java/TestWallet.java +++ /dev/null @@ -1,1190 +0,0 @@ -import io.cloudchains.app.crypto.LoginUtils; -import io.cloudchains.app.net.CoinInstance; -import io.cloudchains.app.net.CoinTicker; -import io.cloudchains.app.util.AddressBalance; -import io.cloudchains.app.util.ConfigHelper; -import org.junit.jupiter.api.*; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; - -class TestWallet { - - private static final int ADDRESS_COUNT_INITIAL = 20; - private static final int ADDRESS_COUNT = 1000; - private static final String mnemonic = "one two three cake neutral benefit quick hip level mother fine burst"; - private static final String PASSWD = "Test^1234"; - - @Test - void deterministicAddresses_fromMnemonic() { - for (int runCount = 0; runCount < 10; runCount++) { - CoinInstance coin = CoinInstance.getInstance(CoinTicker.LITECOIN); - assertNotNull(coin); - coin.getConfigHelper().setAddressCount(ADDRESS_COUNT); - assertNull(coin.init(LoginUtils.loginToEntropy(PASSWD), mnemonic, false)); - - ArrayList addresses = coin.getAddressKeyPairs(); - ArrayList actual = new ArrayList<>(); - for (AddressBalance address : addresses) { - actual.add(address.getAddress().toBase58()); -// System.out.println("\""+actual.get(actual.size()-1)+"\","); - } - assertEquals(ADDRESS_COUNT, actual.size()); - assertTrue(noDups(actual)); - - ArrayList expected = expectedAddresses(); - assertTrue(noDups(expected)); - for (int i = 0; i < expected.size(); i++) - assertEquals(expected.get(i), actual.get(i)); - assertEquals(expected.size(), actual.size()); - - coin.deinit(); - clean(); - } - } - - @Test - void deterministicAddresses_generateAddress() { - for (int runCount = 0; runCount < 10; runCount++) { - CoinInstance coin = CoinInstance.getInstance(CoinTicker.LITECOIN); - assertNotNull(coin); - coin.getConfigHelper().setAddressCount(ADDRESS_COUNT_INITIAL); - assertNull(coin.init(LoginUtils.loginToEntropy(PASSWD), mnemonic, false)); - - final int total = ADDRESS_COUNT - ADDRESS_COUNT_INITIAL - 1; - for (int i = 0; i < total; i++) - coin.generateAddress(false); - coin.generateAddress(true); // last one - - ArrayList addresses = coin.getAddressKeyPairs(); - ArrayList actual = new ArrayList<>(); - for (AddressBalance address : addresses) - actual.add(address.getAddress().toBase58()); - assertEquals(ADDRESS_COUNT, actual.size()); - assertTrue(noDups(actual)); - - ArrayList expected = expectedAddresses(); - assertTrue(noDups(expected)); - for (int i = 0; i < expected.size(); i++) - assertEquals(expected.get(i), actual.get(i)); - assertEquals(expected.size(), actual.size()); - - coin.deinit(); - clean(); - } - } - - @Test - void deterministicAddresses_generateForwardAddresses() { - for (int runCount = 0; runCount < 10; runCount++) { - CoinInstance coin = CoinInstance.getInstance(CoinTicker.LITECOIN); - assertNotNull(coin); - coin.getConfigHelper().setAddressCount(ADDRESS_COUNT_INITIAL); - assertNull(coin.init(LoginUtils.loginToEntropy(PASSWD), mnemonic, false)); - - // Reinit which triggers generate forward addresses - coin.getConfigHelper().setAddressCount(ADDRESS_COUNT); - assertNull(coin.init(LoginUtils.loginToEntropy(PASSWD), null, false)); - - ArrayList addresses = coin.getAddressKeyPairs(); - ArrayList actual = new ArrayList<>(); - for (AddressBalance address : addresses) - actual.add(address.getAddress().toBase58()); - assertEquals(ADDRESS_COUNT, actual.size()); - assertTrue(noDups(actual)); - - ArrayList expected = expectedAddresses(); - assertTrue(noDups(expected)); - for (int i = 0; i < expected.size(); i++) - assertEquals(expected.get(i), actual.get(i)); - assertEquals(expected.size(), actual.size()); - - coin.deinit(); - clean(); - } - } - - @Test - void deterministicAddresses_generateForwardAddressesReloadConfig() { - CoinInstance coin = CoinInstance.getInstance(CoinTicker.LITECOIN); - assertNotNull(coin); - coin.getConfigHelper().setAddressCount(ADDRESS_COUNT_INITIAL); - assertNull(coin.init(LoginUtils.loginToEntropy(PASSWD), mnemonic, false)); - - for (int idx = ADDRESS_COUNT_INITIAL*2; idx < ADDRESS_COUNT; idx += ADDRESS_COUNT_INITIAL) { - // ReloadConfig with new address count triggers generate forward addresses - coin.getConfigHelper().setAddressCount(idx); - coin.getConfigHelper().writeConfig(); - coin.reloadConfig(); - ArrayList addresses = coin.getAddressKeyPairs(); - ArrayList actual = new ArrayList<>(); - for (AddressBalance address : addresses) - actual.add(address.getAddress().toBase58()); - assertEquals(idx, actual.size()); - assertTrue(noDups(actual)); - - ArrayList expected = expectedAddresses(); - assertTrue(noDups(expected)); - for (int i = 0; i < idx; i++) - assertEquals(expected.get(i), actual.get(i)); - } - - - coin.deinit(); - clean(); - } - - @BeforeEach - void beforeEach() { - ConfigHelper.CONFIG_DIR = "."; - clean(); - // Disable address discovery during tests to prevent interference with deterministic address generation - CoinInstance.setAddressDiscoveryEnabled(false); - } - - @AfterAll - static void afterAll() { - clean(); - } - - static void clean() { - CoinInstance.getCoinInstances().clear(); - assertTrue(deleteDir(new File(ConfigHelper.getLocalDataDirectory()))); - } - - static boolean deleteDir(File path) { - if (!path.exists()) - return true; - for (File subFile : Objects.requireNonNull(path.listFiles())) { - if(subFile.isDirectory()) { - deleteDir(subFile); - } else { - if (!subFile.delete()) - return false; - } - } - return path.delete(); - } - - /** - * Returns true if there's no duplicates in the list. - * @param list - * @return True if no duplicates - */ - static boolean noDups(ArrayList list) { - HashSet set = new HashSet<>(list); - return list.size() == set.size(); - } - - /** - * 1000 LTC addresses for mnemonic: one two three cake neutral benefit quick hip level mother fine burst - * @return List of base58 addresses in the expected order. - */ - static ArrayList expectedAddresses() { - return new ArrayList<>(Arrays.asList( - "LNTpgLXprtecSEzztNDmGJpfDr7noC65db", - "LRFcxeV3AQ2zvuFtoiTyCYfSjUQLkEddAU", - "Lc14g9TT4yAhAfHqLaKanr8pgCPBVBksgM", - "LM7Jf8MSp8CB7nt2zYoSZuFeqiBZwn6TPi", - "LUdCaBTEFQjVaZeRafinzaUxWXHZiZcvCG", - "LMUjorgJBdhqdy4fdUHAkm6UryJXLtzaWz", - "LRs2qDU825ca8RurD5yysMEgors3YA9hPe", - "LegeUDssVAq5Z7Y8cx2b8dWHnb1DunMZzN", - "LhNHWWFwSG61PY9duCPN98XDsR8jgiQJRF", - "LPw2hdP6ZvkzWTGn8cJHjinPYCrPa6ZpfB", - "LecbTbQ72258tMyoKFvAPhTJfhdzQwVPFR", - "LPwdqFTxhEZAA5ZUpBmydVPL7NRVHRQeiH", - "LR26gQX2Z5ays6tvgbHUeP2CCUTbpEfufG", - "LZXyUvGc2N23iHaDWWkV7drnvbHEE9LBrZ", - "LVSWjf6A7CRnHRFV1YAGKVcqNunfvmgmE3", - "LaZVKJN1DK2XiujjenaJ2jGtu2UKKBur9f", - "Lht2wBTNf3z6pYN8WWRAZw5aMFWGdMfRwo", - "LMdhg8w5QaTpSQzt9kBmzyQBTKp9vgPrCb", - "LNRZqXKFZoUUBTF2RzumfHNAUcxLXahWTo", - "LKqcHm1EkmggfnDMsMY6mrTx7f8PNMkk1z", - "LUnRSgWNfabKFmDTC6EdGWCNRJmRApQ5wH", - "LdCULqXSmA4dR4hKrCYNd13Zpbb83kjbzS", - "LMFF7wwrdwmz4kxsvyF57u5HBs253DDBmT", - "LQryk76nxEUTipXPZMa9Q6h2sgSKe4Xmz8", - "LULo7XAFgpickNw9ZCtz99u9BTdjmiSFh3", - "LUkdgnTnPJ3JYaDKZz6h6DdGQExentjcG7", - "LfGf5o14NUGuQrVSEHTRNFHqMndZUacpnv", - "LdRfkuPH6SppfU3L7o9PQptmw92Yw4JfYb", - "LXrBKZoGpkuk5e2vZ7RMyDqvzNvjuHxcwu", - "LQNHBjKgpMdfJSnDFNUjF34V9xiAFkgWGA", - "LTjbuCtAwc33m61teukpnPyFmkvLi361oH", - "LYAdmeqiW9zYuuQKDrnJff14J8sUEGZYbU", - "LbReUXLecNu4PXoLfhJqqn8KCE4sLoKBWp", - "LNfwCeec9ux2cNLiu69r1r7omiyEGqqq7z", - "LaQZF3arAKViEhhQR2fbAdpxkpaodsYLxR", - "LezikXzZAHDhJMYAFpRbJYipWx2b5yM86r", - "LZSKM3miJU3wVtsav2PJeyywRXjU32Qfdd", - "LfQbiA2wN96n8mUq6S8jtGVFgUA5JZq4P3", - "LghnhiepXMm1ukyYUsAoQurd6fCv2Pdi9U", - "LNAmNuCMv34C42gsGw1okHXeAx7uxeDQN8", - "LPngfnTyCGWbNwRLo3LXZBrqeN2ktLFTPz", - "LSPnz6cppLDwSGPPA2wi4Xo7Fd6GfazYE7", - "LNu4gkrRYhcWuAtiiiMDrSee47qgQ8L7Q3", - "LP92pv1N8tJzRuc1D3Mqd5HqL9gtitu8Gf", - "LPtgpYRpBHKj55zyVqceBL25pLbkt3XV1H", - "LdstQc2vRERBUuferrsHutHrSyXc8Eduu3", - "Lh1mGh9F4Dcabyw4KLFibKzDA8YmdVGFLw", - "LWQ5ibuQdFvqcvfhrR343Sf5cB3HewwPJP", - "LgaXbedKCRRMLRFXtr6gxzqqGC3rswxi9p", - "LLqKb2SDpA9a3R4uryWLgzC2xYxs9sKPM1", - "LLWEBquiHFV8LGivZ3oVwm5b8arwedMNKz", - "LKHnkgsYrdr6QkASAhYp25GmS8RfF5Ynga", - "LUmahz5XqhGzXEfQ5G8re9sGcQojF7E2gH", - "LTZYrdv6HXRzLDBkqcvZT6CvbGFJ4eFchm", - "LVP6iAtFq2JLCE22ELWmRhRPSM4Zzr3RHs", - "LhRLJzGega9paT1QZoSTvNWfpN2jpu6qzc", - "LdAy5N1wVcrYngqV6UuTKWm4xc61SzLzJ3", - "LafonyLZFoj1teorrjrD6MKyoJbKBBN81R", - "LRgK3ttMmJGAXST8c5w4f9H3hAuHiMgCCk", - "LZDwAdTf3aMf3qYNQAuCkA8B3BiaANcixi", - "LM4e4QiHavGFhkguqVcQee5N5exmQZNFFr", - "LNQ97UjwKtEFsc4P9xEDwh8obNg15V4rcB", - "LXQkMaN7ehoK7KQuPYrDms94YYaUkyhAnR", - "LNFgvrFuVP4SXYmfhBHZ4J5GRzDHrrNhYu", - "Li9ARsaUWwjZXnTQRbTaz8WGQxpiwgDPLC", - "Lg96QoS75svudKyjRW3CW4cw32rBvutZbX", - "LYQiqNPKGHHMTV9qnjVv9WWtUhwnWvTQ6i", - "LWGYANGR7RQyiDmktgdrgz7RGFBCShxtd5", - "LZAiCMeZLzy1QGkwwoT6UuKPkvMqXHd8gz", - "LQ82FZ8GLj6bVavFinqFxvLNbjcrvPCxhS", - "LcofKwE78JwmMrBGvBvUcSmT5YmL5UcSRY", - "LTP7BUs5aBX7C4AHGrVAtLiqQpajmvUvqy", - "LgEsVHXPKQja43j1Rd3xE1peVjExpH2qdr", - "LTBXqH8AN41QbmMXPxotXHMSiqhZ2SGvce", - "LUqaaPcFpqZPhYbUs7uvRj5FVz6NEYiEwm", - "LSwu69aWjEzp6ALhyNWkwMqMyRX8d51dAw", - "LcfMfN1SaMGc4qfSR3Z4bYu9nyKE4ivpEk", - "LZjCL8xUKAuy83uvqPcnkMwLxRB2D1TYHU", - "LXzJfLdZ222RAwNeic7pgrNSkwPxkjVdUP", - "Lej7drV2kA6jvRkiVRrxDZvTfX1zyJAMVt", - "LdQoBbvGhHj5ySGRgVF6RKNUNE53LveNdc", - "LMWLPeJXYZ4VPWB8xpQ56gNSVDCgu6Qa11", - "Lhd7oV52q4AHGnNBXfxRpKU3av7aKN37F6", - "LLZutN6a2NYtLeosEqsKMd9N7Ca1pzAeTS", - "LWguMXkcw5rNRhJ8G2N7nyQzRivYEWKACy", - "LeoaenVAPAkD68xTBqvX9TeesyFMGYeWGG", - "LWr1sDHbDLzyKLkdfsLmRLCVctBAcejZdy", - "LcV3JXkPvF6LUaUi4CsjC7zQm4rKF2J3xx", - "LWTf3PEfQVTfKcHrw3NXgpRi6jxtG9P9mC", - "LZTm2LsVpqFJoiDxKTDamWtK75FjuG8sJS", - "LbdNDivoeGXPcTfq2g5fEEK4zK2m4cp5rc", - "LRT8aPfvk5yqsuAs2X5yza9SQ6GtbENUNo", - "Lh2ZRdHMt5cm2aVUD99o7diX99S4GtbkyK", - "LTTjcuMfedGAuJHL4W8uqxCZmbSANnH42x", - "LXX8N9GBnzeDzTQeZWCEm2xRrmqsJgb3bk", - "LMYYVufrxdQpMqiWdinyVYfmTzKha8suA7", - "LPWUpTu23Gjdjaeg23UmDLmyaeBthZQUbd", - "LRHquUoS73mG5Uj3VtQaVgNmtj77a78MD4", - "LVLmwgtkC2pnuptJy46JZmDJbw414DYYE4", - "LKR4yoQFtFGDn6L4Fb79G4bjaPNrnz1Xq7", - "LW5bvYkYF7vS5tZMBwbo9AwMTir6ywGR3S", - "LZXQ6RRPRGXmSeFagG1EQxf19LsESTHKeN", - "LL41uzfhWeeHZmRpbn2XuhDx9z4tdYBy8G", - "LL57dQUequqnXJCYBT8DFnVt9k188pnQb9", - "LV3AVsx39xauq8t77iN2xDhCmpLf5cmKAj", - "LUoyQcyqFtcmEaEm77fNhWFoDQW3NZmRQW", - "LParh4xAPDCNTUfQaWpb9FTiqin1TY29F7", - "LQBYnEUuw4Wyov5Snv3cbgcpxgoMASz6MT", - "LP1SimqRa9nJgDa1pQ5X2BUUgFbwhSr27T", - "LhnooDJzpsiBd9gBDavqLExWk7HCmPdfWk", - "LZUbTRvrcBbJTehG1yL9EhoHYFqBX9GY6u", - "Ldafj4ehAHY8khB1zhCqXeoxtfCqbX3xhN", - "Lbh9xiQGaNvC1mj3J5RQcrcQgMxCsLPui7", - "LbccvJV7BKYLJZTGBeJLNPjnPz7BKJnqzi", - "LbxaR312y3RkAi2dnxneA42mN9Zf7Y4qmL", - "LNJ5kQJL85HPUmAfuqV23k9jVbrYsqFqTV", - "LMgSwsYzXTTPQBh7jNkY8b5J8kS1RGY3bN", - "LNGsETiVjZTiewDwSx6MBjtQJ6cjxsv5kP", - "LdYux77EPviPYsgntTLSoh4EEcYmyGWtnQ", - "LXjaNYexsVg2cFRxb8JqatMGHMhwW5wtXX", - "LRQ7wHfHYuLzXQH5sQZXfc7ZmLUi9NLTxw", - "Ld8osEsH6XiZrJGtL8hcpNRrurqBvpf1Pr", - "Ld4HAEWDqBt8zn6xnppqUfWiW7jqs8qs8y", - "Le326jYzh2HFkKska2SriSGUJB2Wg5MxQh", - "LeHUeSb2gNDA1zzwJT4ZHwmMZRWWSeX7dN", - "LhrDhcG6qKy87wFxyC9pEVkFa2TxUKgVQB", - "LYK79wmjy3t2uP96N6GPbDMJcwM8hCJsX1", - "LY9Xow2RgxC2zfcawmPhk77zE32b4sgZG6", - "LfCG9qkGpAY5Wj7w5cNforfxTs7R8MgWTc", - "LR7FyfFoYoniwGarnsCXmN437i517QJL98", - "Lgwx6Fj5gsEZBpZHdUzoAfDa6UmTjb7sPr", - "LQMvS5eMYSFDa6yxj8hr7m4aPPiCPEVae2", - "LeMGFqhYNMfcx4HDBy1NSCWzLW7vAy3oJ1", - "Lce8zFao77eQUiD2bLJLieRN1gy3iXgR1U", - "LQ5F51XZVHFMr6sDoZL5YUSzaV4WLnEQHo", - "LKXBnp6sG9vqz2fQTQGsbcgfkTwHu7tvAT", - "LToTM5T9yY2RwwPSw6Si6a1g1juK5SwRCh", - "LeqPGow6kzcdxdUbeUL4AUTwnTaWLGfqwP", - "LTo82EctUtn6BoGab66tEttayxpzjusHff", - "LSRUCGARpgcS3Tnc7QWnqkCEkD43z5Fezd", - "Lgh9WpzeBnr4fCzuKY3VFe1Pejh5mYGvQH", - "LWgUo4CsXEr9CDUraginpK8jLteb4Cq5hq", - "LgKS4NmCKBUp54oxEUfi849hB3mTG1vi4P", - "LKnpyE5t1T7EURWxaL1q1rawe51MTq1ajQ", - "LT8fnKfEjfenPfLdKjKNScRFHmdxjXitEa", - "LPjpeXCf3WPhjpYYw2sZCSjNRfv5bHMTBU", - "LVkFzCU3rPLUmPjjjXi2sUtzxPsb1LKB4m", - "LcF4dwsXTdHjngZFq5A3AYnCfJhBvSJ62c", - "LdyVKgdUJg947nD2ig6awLwyAenmMAZycw", - "LKWNJcTNGJqvdFrRLGZgEuvvH4iRcsytKG", - "LX4Rn14HUtFPLtvQvV6hP48gQ8NpCavmJ3", - "LeJPuEgUH5MzUVcDGwwALYa9X2d6h8m1pY", - "LgRTrcgMPzLN1bojHMN7njFnBNPuDS5zjE", - "LWzr1uppdUef4gvJ1QE8jd1Sa2DZUfaEVc", - "LWKuK8anQcGLNV9nrnjduXbYvDRF4Fj3LZ", - "LdsJj1cq4QHg7pVcmbKzbozmiJxiYKuZfv", - "LRnN2DzvhdUCVzQCzCCwMgDspQCoeojzwb", - "LM2fxgnQTCnmyggy7KKhBXbJQStbVo5HeC", - "LNpNCPsNruz6fBUVkRFJTyh2kp2RaicfhM", - "LKnmkxPkDNynjvKzZi2mdwhy3LGs5dzry9", - "LRLz8rLbdog6wYJwka3CVKAcXFzxu9JxEA", - "LbnftHPQ9jP3r1gmNPdDoTsDUp7xfKBtbV", - "LSVsppscBXo4j3wCtikHP1UBeg3tQXxJc9", - "LiW9yYR7CvFxZs9YpTx2JgFtk3yYqegjDE", - "LXcUo1ko1jC2ycX5nvdZfsd2eyJSjws2W8", - "LfnmCHZ3ySVy1aaDwjzA1wcrqK9SoyxsnR", - "LTRwKGCtV3Q72WL9wRoNkAPxuVW8TN7jni", - "LPfhiBxWEmnk4o7qoBE32UXWkuxsJmTsw4", - "LPLnj2KYK36mnHhJAV67UqtPhhyEu87zbm", - "LbSvp2mP4EQpfmQQMZDWe48SyGhyDmKozs", - "LeBGoKBvaVfWneme3bmtCyrYi36QqaEQkB", - "LcNWHL4PyUnrQC1YHUjWPa9bi6mdxpysGw", - "LT8Wd1Yc7JSvkuWXP3Qg14vnzdKBc16WFt", - "LSRgVmauy3UuGr49cQ1Ud6nKG9W9VqrfqW", - "LR9RVacNWgConsYLYnSCD64xzhxapAzys7", - "LfBcxk9WpfagYY9oVnomeNQJGCijNPqWBb", - "LLtU7JvNQ5LhmfbwMazNjDzQ9wB5SrmaXs", - "LPpvKNJXvykhtZCFeCTfCJGkAYiN8jZYBh", - "LbXYCzLXBRDxuwv7Kb8VBf8YjRdJ8Zuc8b", - "LTjQkPWB4NgwXPiZqGT84gjj1UYGjqRbPG", - "LLp6MjHZKdjWGKQe5WGc47dZ4XM5qxn2mA", - "LNh4EK3hND8PKDdFPuyzEVWtr56sb7FAuB", - "Lcgn675CV2xcPKPjYvqLcvKwV86utNkTAu", - "LTw1ETkVwmGpb5HCkmJvERJEsWNdZEPRHm", - "LTK7rNjWEqdAAALKyGB9nZwQTaeknyUXNa", - "Lc8Mw6ErwuEdHVGqLNFMo5NVGcKoHnZPCE", - "LSffxhP4C8SQMexuzgChTmNRfvyLqwncbf", - "LhnPf2VirEeYCrKUbMSiFzkPLJ2fTmNtzm", - "LTTxY187qCEh4dibgc3onwrqyH7FDSzKBX", - "LPkQHme1tmM7tqLsRR1soHUKGgVaLCgRfj", - "LZBxTJ6sZLmTrEDAADrpEMGoanPeWvvRgi", - "LfaPNikFG8AzzpjKTEJMkKQnjPf28rWjMP", - "LNpmRArz6b9ocUsaiGanbnThWxK5vFZxjA", - "LZLBUYZBG9XYGCzSzkW7CEgqnu47AQ83uu", - "LUnNY5vj3xK61o8mJJiKXfLGTq6M25FB9r", - "LcfVXgTxv7Y7F8EFqtyQ37gzyGSDHs2dke", - "LRPcZSWR949S7v1UtsjFXVjjFp1MNx6no9", - "LQRTtbZ7fyNbLifp2VnMjkPAmuXSK65jUP", - "LKKz9XTUkwrwLfA3CkojuHdQbR86addQq3", - "LYUTtt97x26oWJdwFLVFProQVETX9Gv2Af", - "LSFotdspBDfvK5Wf6D2D7nDPPJWbADPxbf", - "LhN96GZLYo1aqM4tdDqTZWFYV5sMxbJ8Ud", - "Ld1VywVYxotJCJnYiJXYcX7vRBCoDKDHKh", - "LQQodxx3oDCTnWTaFkAWUAVu2Z9S7MmCdL", - "LQp6Ajv8Y221izvDSGNjgeykJbckVgxxLN", - "LcGUwRaXCfLiKM22f2A8FzxZSRrCy6vhto", - "LbM44fAzbpuFk4U84S1KxfwAtUERwUQs2U", - "LPiRebPFnteKKFr8RtGDFCzkcFiaxcXMAS", - "LgaBoSmfv8LLKdovvDs2LpMQqDSL4S52v1", - "LcGrF1xqc2cDWfxFw6CqRRQwKzuuXzEnSr", - "LLvufEqjFyDSvG5w2F1fcTnrWAyTXAoBsv", - "LfHjA4ViNZt6NJWtSa5w41H1wuG37gHrr7", - "LKii7xfkG9uPimmwiUojzPPFZDQcbVBfwR", - "LNsWWFDfuG2mi5x5Vh1ypKR2cCfsrKKtdc", - "LWFgLzQBmwiG3KJhXSWECM6oegE5UcRVLd", - "Lfijr38CTnD5FMioHpwSpreCWGcVreLf1s", - "LQDzYkTfNNkkmETTKEcsfBBLgDio7Ch2RS", - "LZWdM1JivYM1gpgBAXugJcqcvfhr2DFPAg", - "LfwPuqWsvqaTAe4VGh9AHFu2f86mcHQ7C9", - "LRwVUNWkhEg4DZNXPboWVMgPam7s67HYk7", - "LR5juRP4QfNcKtswkPU2z63S1VeUHUSTfZ", - "LPaTHZs8311nZggDGFpbLofyyDU9zGfnxh", - "LiXwEJzji9eFwg6Qa2HbAW19DPrQNwNQ8v", - "LNhFfFqYnHEKHdYA1cW7gh2w2qVaRQ3Aga", - "LPWL9SK18JutKwbJBNGLNoiuounbkVgr86", - "LSatLFBU4SMD9nv9R4ageDVRDUTtQQhk8L", - "LRMLqp42TfwHgxpLs8JbJfWJqfs3ZdcGbv", - "LP1enWqWhswMvPQL5HwLKJv3S33oHYgvje", - "LZ25qiaYmv7DtKe2NW6eMsVgogHw76aS3j", - "LaEedjPMoeY2KiQrSqkmotUeEPivEub7xY", - "LTb1UmEfERgmQofhkswsKEBH72qPCUhZae", - "LgTVzwEmuMzDBpHWryXFiHLPzqbMbkbfUb", - "Ld98vrb3Fr566YeqiP8aeJveELmqZaVcuR", - "Le9Db1J8ycebrWFQPafgtxo85NFroj6bkr", - "LPgtvGYe2r431Vor4NtTuCsuNEu5zdbyKr", - "LYd1ypLRtEikKWKjiaP6t9doBfrBJ2LXt1", - "LZ4NFJ9Wo6BXYfX1Fu2hc9B928youeR6Fi", - "LNv6tiwWTJneFnCY9DvLSS5gDi8w45Ebcm", - "LcVNwNatde6YvzToAz2aWFQhDZWe7KYTQ5", - "LMdzgk8G16GdbANbJ2V1scmjirRuduASZj", - "LiAQUYEWK9LrWo3EVVvArJjWroB1p6QW1w", - "LhU8hfcGUAVbQtVWAbzQP6QgnSAb46sdez", - "LdQizQZSqUy9qbjFjbutwC2Ykm6HT1aUVB", - "LN3VXb81XUN8GRi88xiAjxgRK7jmpHSh2T", - "LLojnet7SDEyE6r5ypEbbMoxj7hQHwWtU5", - "LV2ewWVcTXHuoZbQueEnjX1JmARVoFzax5", - "LazBTecQrBBWVoAXGudW74swL8U8qyD8Nr", - "LPtNcbAbWdaxUpVJdwYLZfkxi6wBFAmMit", - "LhRcaWetasKhDAYq2XbqeKcCapGMEwv8zo", - "LNSmDGKbZMZEniXeYuXCYYeYYMkywWabcg", - "LXWS8N8XxExRu1kgj1Pk8DLx9Dvxz8Q2fj", - "LexYfRVCPaKKVGFLstMTQqLDpckeigssqK", - "Ldms5eGY8zJS6FWJxv2EVXpqRi8py4T7NP", - "LaoL88U7Ry6Vu2SAXmCJsEK5vC756nXg97", - "LRgX8PJRoNWq47NejvP9JCcWLTy3GDhGtq", - "LXPSjGUe2xWpy9V18g14JPUxrbYa4zC6gk", - "LVd67tYst7wEaygdbyTrCNnkUCxGwvHMQT", - "LQRjVWXQN7yNLEWYCRCGqD8DZ7ZAnwbGFK", - "LfCwTPmYxcg6ptZt1XsZpXVLD9erm8icfZ", - "LSSeb3WyehJtkN5pC54p9WoZ6pZCqyxHc8", - "LWHGWCrcvRRDWkUz1XRxvgyxYv5XaBY9fx", - "LfnKt6eJ6nEvRVLoMEgy9D7fad2tqiMB55", - "LVoRMs8mfdNUkx3U9rWhHah3fc5jGs32W5", - "LUqSfbzNGBbStRdC3sQZ7MPeHkxbcpdu37", - "LSJhaNKiAYiLG3LfQFQqyRvb7aeJguRzbK", - "LTnJZT9Jz2fiyYCpnoV3wakoxqAeW8rHgN", - "LPDtzuk4TrFXaTn5fD5eyhiVernTJQVcwC", - "LbhFP1AZQ3p2Dg9upDtMaM6L6Y4AP9eViY", - "LfN2hVwHCAcSoanTSmZHPZpTenL6NSxn5b", - "LN41HmbM8GZnjcSfhUWohNL5JHzYc6Q4yS", - "LWCRTvjLsBPpwxHETBYmBfA6xT4YcSgot1", - "LWX9PVUBK7TtSGwNPb5Dg3pLCR9NQZuUtp", - "LbV1MJ8C16snbn1J5cWbcuAPXbojVBxHEu", - "LdwyhS1mXxcwwL5XBpVYQ5nmGhZKXBZxB4", - "LcMGYAjuRVVv4NeQgeyAQxNygtbTa4VKAR", - "Lhk6FBbTkBBAqTN3WMhgNwFUSxxt4wQdFG", - "LZaPP8QwrKMinoqVa8nuVHBARUFJutrmaK", - "LaviUsiNSthtxZqkshztwzgJFSoeRwB7SM", - "LQSCpivkXubZ6WSJ4Sj2FYQREzNYE7eHAa", - "LbjGdRMLBN6ZzZzCEmuSMREm2ppqXxXxG9", - "LXhdBAZRqndks8H86sauQHjdMxfauYh4qP", - "LKULCTZmekgyPJNKKr28rYRhMCuYm2o2r1", - "LhRHZGQND8pLJkYSNd2SxYRQpo6zNb8c7J", - "LS1axSTidtddZ2MqJtBMbTqQ4WUfAXoLfv", - "LPisDsmDks7AifRSbohsZRFwzC42wKvAfb", - "Lf5Hu9DUCFzWgBZxnNVYTA4vRvQv5RsC8Y", - "Lf5mNA8j574KiToy93y6vyJNNujgMB8D5h", - "LfwAVBi7rM9EqWgYVuxgy6s2oruf7rFehH", - "LS4NFvwhLqUZCVUvbsTLxmUciii2c4TXEa", - "LZSXn1cyh6uVGkZfQ3knsYYJUscQ1bH6ct", - "Lh5RVPHaS3XESVeciCQWU7fX88DLXJYohN", - "LeD9p9Z4cs6djuMY8eViFuAEUwZ23SAm9C", - "LMgXv9fa8LaYhT3J5EAvmhAL69SuSQVYj7", - "LKmjzHfHJjy7w6dPQPRbHKUrR4DmJmfVAo", - "LaLBXX5RPuBdtc9PmgYj94xnxSgafXqxLj", - "LXspfsj2Aj3JifsvieKJY855FqbjarcRLS", - "LKMfrpYj2Jpwf6GbcWKVLVXQr9y9Gfgxpj", - "LdW2144tJQy9Wia9WpRQfHWYuH97KPetpc", - "LbrsYcvTG9xPRdhB7EPzGrDBVMBSJVtz4a", - "LcxvDAqY44V21NQVU3nDNaRZbf4j49zg4P", - "LcvQDwfh9ERb1wQxLcbBicyaJ133mBDJJw", - "LZYVtkTRrJzpcx5LY5cY2QgXZsrqcmPHGv", - "LRjkE1xGxLGTYd5PuBCh6JdLbQeBWjzCXn", - "LVce5gDSjAYN8BBDYrYNAeJJj2gYqs4gu7", - "LYQKe5mWg3QWBvngMBD5YCjqaWuAoqCFEF", - "LYySS7JcpcdPcRWMmnUNLTfwdTMgLpZNqf", - "LZoTv6VVpX7vg4whaXujmySxRENuJgkp6B", - "LgUAkvrob7L1Z5J2renUjbVDoH4ipPtyrW", - "LQz9t7uG7mH3CGtaZvUwcBS7w3qMXnVYvV", - "LXggGj4DoRu1PoRCrS4ZriZetn423kNjtM", - "LRDL2tAEDY5Cp21GMxq7mwSmhtCvKHJYmG", - "LZ45DzMZNQoNfeQvVDH97Bbyv83zM8b1un", - "LfLkJ4ETAXFDYdguJYkoumr73rNnvCpRWn", - "LNeH5HhJrcY7TPH3cmJYuPFLs7MRZJhxS4", - "LRwMLedPqzZ9TnqXupuvo8ZpPU775hxfoS", - "LeB4sMZQRFPCNTaov66FnJcMzznzXMEcrx", - "LN58nMYRVDHZMbCYfv866vstjpQYfg7P6L", - "LXbCqAhCGsN1mwTfKmPB8E114CiQfH9csi", - "LfCUv38QDqrJcrf6C2cGTT7cQ8v5BgScUd", - "LTWee5WMtpDek8AvTJJwQDKwUmv5P1kAMp", - "LaKcV5pyABMRWfiwZv5FiEvcZztcmEp8GF", - "LZRoQd74qGjvnoXHLzb9Ksnw4W2v3MD7yz", - "LQ4AU7TBv7NXwn2NJddxgDhtmFr2J8senR", - "LVvfLWXmQaL1YSByH8tVfJkqFCwZcQdJUF", - "LgTpi25reQc7gsHRqbhTpJnUg5ZHWxWc5L", - "LPUjQfZhzEXLZys6EQqt5gm4huPHk7GLQf", - "LSXYAuAkxcrFX4pjdM7mRxFpXUYVHLWMWk", - "LZnVdtGa6YxfBMUDS4zrNLT72mon7dKqkE", - "LewBHdJvLCBfsPSVWch7y8bXFP5F3YCqUF", - "LfGqWfaNP26H4hTQR7h1G4xctuZiyrT8hP", - "LKVXqiteTcAaRYe11oQDTbxsjHNqPzmFxH", - "Li36uQm2C3ntDq35KWEkds727hh1suXGYC", - "LZsrqswTkjyxtpZXfSZ4oGEHqZ6PSnKdcr", - "LcdTZFftQE3mufjgGRBdQ3aCD5tg8tPX5z", - "Ldj9MK45RTiUvqYjD5916vLgkLuZwmDGWP", - "LdCzymEQLHtXQnH2unAiZS7myPanA24vaJ", - "LWfaCBDTZoLfQjb5k1ForKMd6uwxDyFpkW", - "LRgbporuXdvyrKzdq2W95kkdwPEHjpLw2f", - "LQU4W2hUknmqgUSBZjtD2Ch85M69HARHXC", - "LXsnkb7A38NDHSJcD5jrhX8iqwFWqjnYFL", - "LgRQ4TmFMwW82VEPeSXYXKNZAGKWSHuUgP", - "LVcDDKskQD2TqnSSfG2BZG5ufqSmeagxfa", - "LaVv7yH1k7upguY14k2fbt3KfQK3zzkFgZ", - "Li6v55R1F1XeiZZNQ4RywuxZsfssgFHtia", - "LZLp73FKyUYkN74Sy384g4EVUfsD1EhUWf", - "LgWrxX3rQJwiY69FHa93JeW4xsvQgRWxBo", - "LQWm9GY3ciBWCH6bgiNHmPTWmLM5oKtRaH", - "LVqgHt9vUnTB9ZzUYNYxwVariprCbSAv4A", - "LbEsbXSqKZ2Uz5zQp1PTa5UXab7ikXTmSw", - "LaLE1W9XcM2fKRENigadm7DvB1u6FvGgqG", - "LP5cJdt8gm4uB7wyHHB8s6tYmuKyfcPSJs", - "LgfdaifGiyQEzhdCGV35Fwf9536CbaP9AD", - "LMt59pFjsdLp6MGDQQD9VRbHBZmatnFRg2", - "LX7e5JY4yfQGQzcP28NEcZzrzzMLexFCc5", - "LM2cwqThz5yYcUE7b4D2Kx6V9xsfX6135B", - "LVTbHaiMbuK5QJ9iezdwbJXkZxXRNWXc5G", - "LPkSDgscisHp8BJXeUztneoRNbkxJuqhMm", - "LfczRDEX5kxcyN9aRa9S8LD82QrrxMxR2r", - "LLmrgx2pGLQF7iGva9HookfnRgYDXxR92o", - "LRSrWxxwVDk3TtQjKMwaqjJrvp6PEX2hq6", - "LdsJaut1n1oALn71QDHeu5MKCJKYsrbJWL", - "LaTaJDeBb3hwYrMbVd7KgpeJA2ETu7bEwg", - "LgkRjuxxpx6Z8cTJ6A2ecaH6bCg3KvTS5U", - "Lea813yj7mcDEDyD4UxhjYjapWiHTkGkSC", - "LamHNJvYsnuQ5rt49a3mmyttGguF3hLyTq", - "Le6FcDL3NCapwafw6CMcGpnQKByHrzusGq", - "LfnzRrWTnmLKfXDFSKjgKbrsnB1iFrk6yN", - "LZXDUyfNwcfcsNAdr7yaWeDKKsq9YzAK9V", - "LXjcmGbwimUKDb85payAmrQmSXirDf9HJQ", - "LPnJYTcNKBM69Qmgna6yf8FMJqTvSAoNt5", - "Lf37qavaRCar4Wj9Kcqvhebaj2E9G8JB6q", - "LWjJHHZ7jeWXHNoS8SL7UEyd2gcSCFoWnC", - "LXbyzfyrmFonXmBk5GkPxiaZ1qyUZCxKuX", - "LKgfwR76E2YaH1yi6UTV2z3WftBM9pXr9Y", - "LYy8Nh9XPht5Mwnt8624gyc6axhFMAUAxc", - "LNpmSSJuq6tzt5tS9dZNJjuXhezDmUorLK", - "Lfv6agqBwMsFG3ANKvPysuJyQUkwp5XN9J", - "LfcDkWUBDhWVmF3Utf5Ztk4QqG7aehrKW7", - "LR9FK6DZn89Ye1cNu8QAtxZAVrT8wYxC5X", - "LLNChpKyyWgerYTKVfUFk2oeYUFyeZuEoM", - "LRGDjdUCZ4sAgUTK3m5gVDJA3R3rAY1nW4", - "LYeEZr9GdrCgei1RRWiRF5Uyqsi5i63gTj", - "Lh46Rk8bHk3JxomWynjBKzpJg4S6D4s2NE", - "LKtFkG5xJKdNnejKa5HxeUX7y1HAsKZgNP", - "LRKoBygqMvJYmDK8nDpSk5nWZKd2xxzbch", - "LKYb1yherT6oxUoMw4MTLyV51gJ1M75k1t", - "LPWP6xysXnpNXj8rKKPwKUKawmxymmTZ5E", - "LRcoYZSRQ1C5BLeCiNC46xFWhDPQAK2u9u", - "LS44nek3ytjXDxCuKDepHzPZb17LgdqVTE", - "LiAfwhdiwTd69BznWPi2nLjnsGc8aBH3J4", - "LYofrTD6GZZvC6vxF5iKewLm3Xd3iDWz7R", - "LXftf2DrbUVZBC7Ty8S9rnyrv6PdadXCZJ", - "LSazm4eUAaE3BuVVdL4q2cMhdbuwzSMmcA", - "LTVee6L638kvdPdYM7MG2V7JZZKCBwPHB5", - "Lg9LW8Q37hwbcX63RpzXBKprdei3Hocj4f", - "LWx3avsgDwffYTn47x8qtboTUM338c8CKJ", - "LT2PGJPi6xhzeNopVwWntRaxfUAxJzGo4n", - "LKhsCQnPVNPjLWPw9AesPG2rsbYaEbFfVH", - "LVeBNmWUYGoN35MPPb7eogwuApwosVXvNc", - "LiYzqbRHxSdU91kKGHRiw3k8UrPMKV4NXP", - "LPLFKEmRrMkKThEotv6LXSP2ujgLAvvhhG", - "LWuUFRwniKhHYGvpCJhHDsYmiqn6pP1NeG", - "LhrmTf3A8wN9uTG3G9kHzf5ogXHmVM744e", - "LcTYdKZvrwVgkHMsmqsLyRFVgUHJrXLSHQ", - "LVzanUyN8NiSKtspPUCAC1FHXASzEZJMM8", - "LKNCFcQue4RbHkQimmxByQbt6guqi9keYz", - "LZHi5sTPdUmCnmtCodNR4v5NG2BwAqCGHX", - "LgYQybURgdTLSZ7C2B1MhHWyfm7vt8wvW4", - "LdKLBQngeqcs1km3T6s6ZiDVZsQNdKo4QC", - "Ld73DCAtAiV6JccEsMvojGmtSNza4SLoj2", - "LVKWHWqE7tZp6rvmLhVFHcncSpQ5Mwf5pY", - "Lb7WtrcZgCYVz4C2ojjhAea1LWGX47p3mu", - "LU2w7DZWzuSsiSv9WxC6czySDdsTGtyhVG", - "LiMR9FFdEMNmcygQjfaCsN29WpJhQpwwub", - "LcJCTXGhoMc3882LDLWSvVrSvchgcrScee", - "LU1ozeChHY4Dc7tCVSxWz9cqptBgQ9pJb8", - "LMy9TFp2vUZJh1aBA4nSFTnk9dQrRub9aq", - "LexvTJ2Xva2Qx91Br3A6doygTnQrC3FaHU", - "LhJSxkRoXWB1imF1XLTydFGjthNdpMrxRC", - "Le3zA44dL1oxHjyJUySodFnhrQAEZu376w", - "LWdFGJmNPQjvdi6o89MDaFgQPLfzgpmvmv", - "LNqVqTuAi3WAnPr8v5UCb2K9v5xF9xVkUX", - "LamEb2i4o2ZQU227JFYyJ4JaiYroLh1Hoq", - "LMxqjT3dU5RT8LuoXnrZfHQu2wwPAZCuXh", - "Lf2Kwcy9hAcRdG64wZYhga7yvajPkyEQYj", - "LNcA279mX7GqQXa5NfJK9EiUCFvTZUEwHK", - "LZn5SSYFXbMNvWXi6Vzch6sYnEVm1Loyvp", - "LdpvugbTixy8dGDYvojLKC3UxzwQikzE8i", - "LfginKkdNZy1VpXtTspLypqk6KMcfGRW7e", - "LdVmYQAcvtpJdn4Zw21hynWcMxEgNq1qik", - "LdiSKq5rU78uXzibkxXqwfd2eWT1oV4WUn", - "LUaX2jyjQz2NwPicXXqBdYzYowXSeM16yN", - "LZZszxqfznEeCxdTV5t7U2yBvdM2ZJir65", - "LKvSyL6FBZHgN7sL7zVcxKuaTiEPRauqXK", - "LhXry2d4HrVk9E2acACooGs3KgKCDUzL7r", - "LfvCGbTUtRe9eiKqJhs8PAGor9bwqjwEsT", - "Lc98AoHUML45SAGgpzsX5LYc7sPCWpftNi", - "Le6XXyQe8VUbQhYUTn9wFY761xpLVETwEn", - "LPs9CEQHL2Ef3bKuE2BxmfWDnJfTvdffyW", - "LUP4moUpoS4ifVBtEBswFghVWqLdZ5qTYX", - "LQRXpcSkx5d66Y6WWZWXAcQoEUYbXisyR9", - "LUyQxiJqBRit97K9wdiVC7z8GZs858Efay", - "LXcGn1y7Vof9RqCUgD1msBXX4TKbfN78hq", - "LNqQZNJdw8jpwkq7MC5A1Z2U9VPRhxCN7J", - "LNYpdowmDHHkV5kpmX3BzVBqSppSNW8Gr8", - "LKskwLZnzZqCFQunxsKPqsJka7WuW5Yhc9", - "LR15VwhWBw1pnjabSn3M3VReFGmYowrhoH", - "LMzh6FEE7t8QatDihJesgjndmKCEB3GhDk", - "LVNMUN8yDoE7TcbPi9NWDeUEtRj7zBnmD7", - "LbaZwbxNhHiM5vnRf65NTgtM3ibaZCiMSf", - "LcUHUzb2Whg2jww4jqgtq2HiX1cjoW36Mz", - "LScrZ92cYWGz6hszQTryFQcQe41hsd4MVS", - "LXdsv51o41qLX6hRTeScx62DH6NTrBsxP5", - "LTTH7rDBRNYHUurrFBoUFVJCKu3KTL4VEu", - "LhWytosG8nvC7hLDVnuTc1EqPsSKNSXW6d", - "LfdabKhvDa9rzM7JUN2v7g4JXUbvAmn5aZ", - "LRWd21TvMqRd7WE7FowMBU2ZBZjW4b36gs", - "LaEdURp2MHA2FevyhFj22rjfYZ1wU8Wb3w", - "LXNHmVTWLMqnavNwxHUgBRQcUa3NLHxQLZ", - "LZjcbwokoh4Wyv2zqUdjW78rDVKX9H9erY", - "LhFZYaLVVMKvAMGbfEEJBqfNQLsBiDukS7", - "LfMz6875z5cFMPpdgQuYDhdYJjbuo7ydXt", - "LQEe48oXSSiFsKorrFBtTk8EunQv4rU8R7", - "LU76iwcG7HFaw45XB8RxjrBk21DFGtpUxm", - "LUcxMHYwemdh6GFMau9Zr75pvik9n5jVjT", - "Ld5MF45i4U4kFj899L1NY5j5J55YSA6eki", - "LW4CWN7rCAeL1hhQENBSt6XqTvUp1JA4eg", - "LTuJrHZK8mALecAM5ymjVXkPinwcmUwUe2", - "LUA55GSL5RuB7nNqN6w9k4EHenGqRmpoSa", - "LRqPW12HWzSH1CHufFEThj5pmU1oaBFGjN", - "LZ5jF6KjXtMJbJ9s9rYu7LvUhvMWhqCF4X", - "LagEhMhov4wZmciRdamTGmGzwFziAuphY3", - "LfinWg1VzA8ibHWBH6mVYd4VHKr6NHtKq1", - "LRiAzfYMPYrU7wbg6Poi24b2rMR7fefWpy", - "LUsBVE46UVrEHXxWRDB7mttenSX4p2fdzG", - "LeJUwJfjaghuWb4F8rmUrsBcj4Az7GTuRp", - "Li1sLEgHeus5w3sYdnbtJZH7ySwP6mnEhV", - "LhDVcUe4F64Jr7WJYrXpWLWW3Uim5A99Gf", - "LZL5RhLRu5JpSXWMhfVkmdZs34g7zftRFf", - "LWt468oMQ2qzDxAYkMKiVwgSSFi5YiWtHf", - "LT39rRFHHCyn8QNYP6LasYkYCrovdRZWCv", - "LPfvXnzQytWK9kvcSbEJxPaSQuKYZDPsQi", - "LZNYir62U9fRDJuPiPsrKDEzaDePhJtJtq", - "LehtRTWzzkA1zFCtKsrFaK1KUd9dw3dcev", - "LTV7ndtzN3N51GJkvHbePbibwRHQpazpQU", - "LTysFerupYwh6Qb34WFieDArbtyXuUTDQK", - "LNopEK2qHxsgfJmsPhJbTXQuBzmBNVdADd", - "LMLpvLzf28BJgP75ydLw6NHUMW7svpKa6z", - "LedjHgZKzmeGp2ehx3bTTmRjGH4dDztcue", - "LRTUoFmcs47RCcuDPc1wfAJwawCx7DGhH6", - "LM82cAWrNBeEUmBDZiF8TV3yaa3SLdQm4g", - "LZFvxgQrSb7rDX8tcy3pA3vh8cRSAxvaHt", - "LMCaFyi2TXjv3CRddbt2Ha5eX3xEC5TBne", - "Ld2rEdNcSQRGJQwBnof6Ka9jTXKx6e1PUi", - "LYLCh29WQdSSLoHWj4b71th8MZUWG79QfZ", - "LSFy1ACArGPRD9YZEmcNMHxLDmnoPyTpes", - "LPptdyRYSBtGstDYgjwijD8St4MfSCfeSb", - "LWkNyMGdTs34uGbEXYxCQV4uhm28VQoikJ", - "LfzTvK6yahXTDmgVMgM2XHpodqJySnwAq3", - "LaxirQz79hC6BEWGzeNo2EvprUE7zW6TR7", - "LQuWoruvtJ6kdm2rAg6UotNhJJfQcgE3Za", - "LiKpsruQgtbVz1isYzEd2kCDezufNSY21R", - "LiL2MAtuo5DEwsLtgm3oqK7asQVuxmRGkD", - "LKrMYBPBwnQAibxin47PHXKVtp8FdWTWep", - "LQDGoqJT71et48KtZHw1M3TWt5avB9ZXX9", - "Lai2oCdmNfPBLmdEq8Q3G1KEwazqGBb9JL", - "LYTUbJDrrWSauohGCzpxkDfJ6XshbzSvYw", - "LPPNcDbgNVwQKak6pgHMnK6i9EdmBQejRT", - "LLX3dw4EEYL3DNH2eMGoXxumJFJjdxyxux", - "LfNkW8BozxTraZRFQ9kVRZYopv3E2JNnbB", - "LTbDScAZKeL5cnbrECSM7J2CEPGj8Taezp", - "LWX8kRsA5xwSRFVHKKbuXY7V5KHczcvQxH", - "LeoXAWCsYJB3FcYmhw9wQhDohKkBLjiDUA", - "LcNSzD66uvEAP31gcS5mcv1wyZ5ffsEzT2", - "LRkv8FEThwrQkxpf3jFkv8hY4Q3vonwwMW", - "Lgr7QtrNgKrS4htZyhmSp6S6C4jDX4d65W", - "LeJ54WUpjx6tKpBMyFKZbqcWFMAZqUUkKC", - "Li7b8UcA7jp7EMgQ8oNsk6JbV76XV6BoXr", - "LeadTzRiEFVBjjXNJwb3T7AW8ywNkEVLV4", - "LQHteShBs88McZVeWsdyqztHLqBGxdf8Yv", - "LVh7GPmjryAyxxEu5i2rFceAjEeAj9EP79", - "LS4d4NxrqeTUBjVcsaTLYtFHoPWLe2x7ir", - "LTeEzsHxdrMtE2wJdLnDtmJVgJD17U8bem", - "LhZREEZF66XgJFzgAnyThSBbSeoKbdMHBj", - "LapLnKbab5Rt2Twnz9whwsQN1NKnbTETwp", - "LdwcMyu9ygA1zfmDNPgLDVxG9AtQeJCdXV", - "LT6DRkw6XcyXf9pHjqCFYC8zic9DATfE1M", - "LPMVMYFLER425fqpBRghidN65MbzV36V52", - "LNegG5NGKkjTxR4V96qJFuEf7CKkDD5xgz", - "LSg3mgY3FqnvjoEWLTbc46hFPQLMtPCapg", - "LUC3B4C7uVaeScH26xWS4xBe62dv9P7P4k", - "LVPrbqT2JesNv8thx7TXNLd85WNBFyoDxW", - "Lg2SDwobLEhMGsxj1AUdV9AYBskz4a1f5N", - "LLZawqtAeQZpqCwfQVDrJnaPvTxL7RsZhR", - "LSBe3yw2mcrKYkx53yD8daJs3vLBoinCzi", - "LUYp26MjLmWJ5SWaHpccdTx9SYatEjCRhc", - "LejgVcq9oDZZrspvKnrkgeWCfMXGTQ8XGE", - "Lf47jyLKhphuoeuhQaBpy8BSDtP2b8FX46", - "LacqTKQcZZVHXamrtyC98BiMUqGFjre2jJ", - "LQg33sm4kgBza6u1oTmmQC2gDQzi8xHQtp", - "LhQSED2BB5eD2nxSnBuH3cSgtpGRE9Lr6T", - "Lgyym4cr9jyZ6DuuawsH4vU5nGfFJo46hR", - "LRUQb4QTQoyHqGnBtmxmGUmdmPEiBDaHFp", - "LXa7DzWMdrT1ZiPerKkJwxZPs8KGGjotNi", - "LPFvTTZGyPWTzWBniUeabT6PpX6Zzi9D6y", - "LevYJS22KHCWb43JbyBHtQC2ktEt1DnB4M", - "LRLD1D1eHEeEZLWEkq6usamBtkRFMoMQwK", - "LeVxtaGA6CM7m6aC29wZ3hhVqU7QwpiDkL", - "LTMFcs6J2DrhSCRoVJ4VjS4XsbAjtyLFtt", - "LSs69ihmYuq9ZFaZYY64dybDvcRKJ3HT1w", - "LLV9CR2EB97VojFE3QG3sUTswxNLJZ5Zzz", - "LNdPmx17qQ5AfMcThrSKxgg4ats45NSixj", - "LdCBn39uDtnyMUaQrRK8J6M2FvaYoxNvYW", - "LViEH78zotsf5NFSHC6rM1TT7bVXnkQmEK", - "LRwMVqKphLBydypR7xkTFvb5aBibgLcQUq", - "LYsywU9zXUUkmdWajphpEwF2eiL3ERC3Ss", - "Lf22WxRh8MgotGj6QNtsmwBjTi412XedCz", - "LYDum7F3teu4MPfN5TT8sxts7Dq4U2ztmE", - "LbYwshK9qHevNBGAR9Hrdj2ujB5vRq9A5r", - "LP92zovmBBPyfa6EffCWuToXAyDtSjkyNx", - "LLyJ4cDBiJ7mZyi7BFVePC8sKr8151ZLKg", - "LeFScySfBCoWSgvrzkTiUaajX7BHbJ89KQ", - "LTJLMc4yHHg3Gtxwcud5G4bsHgeKQFVTkd", - "LZoMaH46kzYykGStsqETL1gECWAxReWqAy", - "Lag9gsk5Wu9nnyVTLGex9hgKVVv8mBMdia", - "LX8ouPBgk3HemjYTREseKXEZCXuhcRcwZg", - "LRa7BNU9G5wjsE2KxoG5EAg3pWs6wfq4pt", - "LiYjHFqzLsqKvZx7KCCH1VKYV8p2ckbM3J", - "Lfh9oL7amDoxWUZfh1YyN38Dmij1vSYEnm", - "Lb7bTy3kdjs4EiEEAL8iwWgcZ4Be6bPtw6", - "LZULtWkQfjRpSLT1LigDM8126bGtDZQyZk", - "LfMuKSyVh5J38GY59SWswG8prCAucpkcGh", - "LeTnaxgTXm84b17EwJmgovW6t9RADsLiQJ", - "LN4VtcfdHTYmoacP1tpfP1w2uW7gRuhTY1", - "LYQDSnr6hMBKd6x1jky9gPA5NePEdTKMd1", - "LSk8DivGMiXGUy4NqvyKgXDUz7tXi7M6Mg", - "LTuNGVBmKgfdoc13ep6xkQq2qBdAWPcs6L", - "LR3fRTDAGuCq2wJqyQBTuKzEh9TqUo889t", - "LiQHGxYYQwbCZunneqavSmsKfAy75rRpje", - "LSf9xF1KKvWghwc59dHhKg4qdDDQLMe2Ry", - "LPt1FjgHvKzG1apoxvizRp2aTvvpuvbNpz", - "LULUas7LEucL5NCKo85ZoqGwqSWXqfKPAC", - "LddCzGVTs3gC3apfXxHMJDt7DN8Ay2d4z7", - "LRXqDsB9QVAe7JmaeFZxTV5yygQNsTPqhJ", - "LNXY9gjCUANfL565MvBGyB9o1CkkqPxDJA", - "LceXAS6mEK3fyXX9tdbxLREbzZDJGnkrZg", - "Ldo7eHSWqooZXNYfoSKB1gKPWauXGBwAvQ", - "LcqWVgb24TQyD8AQR61qGjahj9Uwys7oqM", - "LULdzyN1WW9BDdTc8hCVS6XHUtUVFfH8XD", - "Le17ydzLqYMoFzyUuVBSszrFMV3qYeiPcH", - "LPbCNLF2QgdLYkrQMNx1E3a46Sm789uGeB", - "LXLap7XRPWGGDV5NW4kAtj9LATqsXmJYbt", - "LYoGvagUXBTkyhxK23tuzqXmkLgE57Coep", - "LfWdzyNfwjEhbJCi2sg9j35RpDXFuqLdyP", - "LTgDa6Xsg63kvZ9pH2wYwDCqesXo5drk2J", - "LYDVpjoYvyEGEtQSzwAssuycoS7VEHGnaS", - "LXv9YPQGQQxxHwtXY6kvfABzo85A6pB6oN", - "LUNRSQeq19vnFfpyTa95RuA9UanSJ8KdUo", - "LbfVboAyyKXQTF6i6hLyoNzKwsRqKFVpyf", - "LN4f5Pg5dShTvTEZ1jXHfZKRuCrSeQHCfJ", - "LgED17g1BsjGx76yPSWaAXYTFf9Mo81L1t", - "LcRCefLQqwHrySie6WYDRwj1iGuHez9wU5", - "Lg2ngZXMJbBYW6RZ5SBRL9w8bZtVQJWQFg", - "LZP613qoCYLDMmVMnefw5RuSt9L4o1YkfR", - "LP3sW4vxFPuyTJC4kw7hhEKqa3pMAjfpYE", - "LhVMmYc8UZHj7KSKPSUsVJyP1GyKff3hgU", - "LPzPycHukP5J95jjiUx6vYi2FhRM95TPi8", - "LbenvZKkJwhCULwRCCuGgqR26rFMZE8Tzd", - "LXqajRBNYWChTpbSoCd2mVtY7gzfzQjPnz", - "La8om9H3kVzt1KCKgp11dURgxCaTqLj3vJ", - "LNVPL3UmQrdbr5uybGVco57Lr6jx6Gqa79", - "LZQ5nJckGbMk33UMob4eoJgi4JJFb44XQz", - "LM5zsEnehjipDxhdNbsX3umTNJtvGgvthB", - "LSTDwxFQki1PSRmhBYk3gZr2qPLbKcPKat", - "LXzW1U8Hk2YKyh2ecTGvxthHocLra2UJDQ", - "LS7sLiEsVasKwbtoidXGudnJB5fHDBNYG5", - "LLz58af37RiZrik2uDPmpc2RQJyNJ1oj37", - "LRLMow7MYFSoQMpfqka5PhJT3Z9k6WqfWb", - "LTbDijjjSUuFkVvVABYdkBrDtRpEsuCMcw", - "LaV9GnFJykDwaUWn9YVRCiSDtUt5GVpvPa", - "LUYJZ9UpqCayVMx7hTyC9UmF9rpEiH3Zmn", - "LgPff5EGamXXnfzHT3t9b3PEqcyRwCRtPK", - "LW4mo41eaLtGg29UzvqNnUqc6yYw6HevXb", - "LgfysTaFcMehG264PmjC6rFbC2PvuSxUs9", - "Ld73tbj9WQokQfyLFeVr7Re8XeWW47FPu2", - "LcAUmE141s87Z5AmDvJjqWvt1Uyqp63onZ", - "LZuXCswGsQQBo3nQaPm9e621GDa7hNPQxj", - "LNc4FeoogJW81pTJoVDjXiBSYsbqdiP4dz", - "LL2prhA3kDjtV7HoTp5hvaotDT8BU6BESi", - "LaQBUxvRXfJSEbpnf49dxqhCeSEVKm2gsz", - "LTmX4Mv4bScp8z1FgW6mEtQavN1c86pT2N", - "LTMWeFwGFtNUHwKvRbReoRSRBRDerNRN7u", - "LLQcxwRu3DR2VTue5oEJL83RbLEh2tML4i", - "LgymiHL6v8pJELfkFRqnWcmQGqpA7T7Zmw", - "LNijHxqraiYvkw1C3CXLvTDJopTWKDb2q9", - "LcPvRMUBNhciUj7ZDinmNMvd2mRDZ5f8gC", - "Lb3XSRYhobeyGGuHyN5uPtzyPcakvhQHWH", - "LbWLi2fZvSbNUdpnotbnfp3q2iKmapuDF6", - "LVeGB8SrXQSF1aD3tB4BKjpbf3mdBRTXtu", - "LMEWoLtwXBhECEhWnpz5frV71EuehX2zHv", - "LebnT4ApLCju1qvxCrJJsP6u7Jrdt3YEZu", - "LQSx5N8jfdykN13tzo3d23HqG64f1bWon6", - "LPk1KKuwH1S9vtyf7HM3qP78UoEHrAnEXX", - "LU9CaMPAgco9wRxc6Wm3FYkuFEzhYX65Ax", - "LSM3bSPAhW9HN5FtRn2Ea9DLKRayQyQx8B", - "LUgSMFvNQzjusUna2Hcz9p75bpEfS9kTZh", - "LesW6eRsSEnGRV9BJjuoDQL8GzWWoNGZek", - "LaCMX8KkRWcYknMMzTrrB8DxcoY6x6XJQt", - "Ld1DqSmpp88VfYX53zo2wZVs2wdTSQj2cW", - "LaT73qYEQ5LyK3YcSFjenQP6MxJMqTLqBc", - "LPMo7n3E4TzPBryFFoav1zfwaTqEU7fkDh", - "LZTefH97XnNiN2cFVUS5PBag9hqHStFBWq", - "LdyN2MJBUfHGS7AHJ2RAnhhK8vru3EirU9", - "LgEsMgND6pxSiwGWtZyETh7aMniTUHUm2M", - "LLPeDekSGQPpfRqQZXfyHa9jgvGqsihuG5", - "LeYgKYNJqdLyD7Jh5EygCSVxsunTVKpiVC", - "LU8fbEyGN4H6gn7kmZDyZPvJdA74LxcVKY", - "LP6TfYe2nRegNzVunREnLxapK7j24HFcws", - "LTJL5QrbrR3zQQuiPVmfPba57edCQtHUp8", - "LWdpr75az3i2dhYGdELRNjcDdG1iiAb1gn", - "LXgkzBRxojgFrvTXm2qeFXN7Dr75rr6jfy", - "LUxero4pkex6r4a4d4JAbJBP3eZVj75F1d", - "LdZKaRExyPhkYPeCZHwDgyX8zoZQJkoUPf", - "LZYY5aHWStB76pPXBtZtkRSUgpFgnBBKQs", - "LdACoqRaJTgxmcPPVLnQpj81mrfvE51u8K", - "LVz1yNKuRyeynTWKtjvhS8bhi8CHtEnzjs", - "LWaVX89EdNksyEs426eyez566uP379jvAW", - "LQPdTLAvpAndXopptBQ2EJDkNkswusweEx", - "LcvjRsJ2KtzbCVCq7dUXZr2cBCmPdVK3hj", - "LV8Mh3KS6EhJhHdDU4b7ZbG4PjkxhiJErV", - "LYJgrdQUV6rUQd31o2oHyeLAJ4yp6R3eXP", - "Lck3dU9y9wyJD2fSfK2dqb8fDwpQRWju4d", - "LS4rnegqmi1i4Pb3N2EPkvP7za8e2vR4gz", - "LTLvVRGGH2yMFmF9u9Mz7Kkqq2AwdJEFLi", - "LKLrHVjWYat4oTLhshmYEsRiCfUspcQR7u", - "LaEyfX2Pd29mXGT6iGzqnrr7uhq1UYZAHm", - "LZwXMbe9qtRWiuNtph8HcALSDzkmfWW6bV", - "LRbqTUWUR2fEJHSgRbJVjFoq52v2C1MUgE", - "LQcGFesvduneESv7HEtkfymaXeH7PJJgC9", - "LQBHTKspLyrqQ3hnCyMr1q2mk4RrT1BFKH", - "LNGjuBnVciDGUTxFQC2bB5gvFE3YqPzXRr", - "Lh2kgp3553BcvCHUsxYjUvrotjYHRgjXt4", - "LhsEHCMaAzGSyvZpCj2V4Wp1HjcwMah7tU", - "LfmRHjSNDXaEmLHEwz59VCUxYfYsh7u5kF", - "LThrqXBHdD1x3uby5J5PCUTWQTVshmdwxa", - "LMHUdMwpeziokMgKSWsuVeLKQryBYtCfZB", - "LgcRVwTKk9jYyvp2DQTsQQxyoyhVphrdVK", - "LdrXpd88kH2v1BWwJtaxohk8LRnr2TEHNm", - "LfC5NbmSeZYmiKvcQKrmz14msUAKH8ZrMk", - "Li3fNhb2ASsoK9jKJZaWs7EBF4XbZbWAv7", - "LhENViKDr1eDcCqEUC7ieQtRkydYABFq2r", - "LUyueRX77ZDtVgXD6G9FWjyRikeUA2Tx2Z", - "LYLudxM8vGRcQaxcQHRn8DzdLj7AAD68qx", - "LfDK8SGgD7aKEEB62iP3Yd82SvtFfip5uF", - "LQpdEHvZKFQF4rCUTy5SnqH384pvvZ3Y5i", - "LdfMWGVYEdDtyR2GHy8Lggz95dQPSaTZBD", - "LdZqGe9HG8Z4jv8oY3fM6DFWVYXyaEoYnT", - "Lg9X3fqua5Qvq5npLAXDfzF24n7jsduFut", - "LZvx36km8ayxZBZkTUDTm4FiDGF5gybJxt", - "LNHE1ejNH26YmU87epxg8SHLg65mrBDeQT", - "LQKSBKX9JwzQgM7EmhgmioYt9LL4N9xo3a", - "LYBz9R4vX3TPSTeoYsKPmEJAAYNyFPQUdJ", - "LUG12kwRamFNEvuWd9X2sFPrLBEKxGDDUC", - "LQZW8XVnYK5e8P9met5drCTj5dpnvkXUDf", - "LXEHZe4KwTfVfS7gwoUVuWsKtSHcsj1fMg", - "LahNS2CDM5skx3AVDU3PqeZKUc3ejsZJEB", - "LL9ztroxHkHEM3AfaaRnWR4GyBmndAQN8v", - "LT8wtacMnb5c6uUjznGgTFAfaaFYmx51AG", - "LZjUrPkpg93F2UUdCXEekTEjBjx8m9sitJ", - "LUC1TrjuMShNaYssEFVLhpBiUaL2sMjpZx", - "LaC2ihRQanZS9LogNNMTAuu8HLdz2uFZHf", - "LfjTwoa3QDxSGMVR4yCkUJ7QV4QXtxswqX", - "LQwd3fFW6k5VoZ6Bq49hTeVBzfQZCzBWEU", - "LPwJsArmye5XaM9xwmoufxPfB23vBxJcm3", - "LS8TFTCeLdB8qxzp5nYF55XHsz2XzRNxup", - "LLy4pPWGkorvB2Qz8wB5tfpP5tzmcu6bxM", - "Lbv5z8qMXWyu9ze3jQiv7viCESorzsZcXG", - "LQcRZZr7EHboLGMSW1VmDvcjCGM99FG4fn", - "LTg9TwJ7XZaawgmpCrVxEvvdBRSe2rtmDU", - "LNkUBp9keeahZzUdzSohNvG3WB5U8NujhJ", - "LKGqws48yQFuToQdGcfK4prxVmTTRzxTrX", - "Ldv7f7KUtb3Gz1zMm3FXS9LSuxx5fVefxg", - "LKJRF3ViVB5uPw1EebUkAVeA11HcMCT1L1", - "LaEEMDkcxr5eKuSf8dPNR7VH3i4aSKESbE", - "LdbUTpAvr62BTB5GnrDHdMfkwe1q61TGUW", - "LZrETjveMnQPzYZpbRW9u7DxoVTT3tLVAq", - "LfDZFiqTJDG6pWC9NwD79sr7ig6XJaSdsx", - "LcVE6S6o4bBMgZ44UM8pUP9yDWJe5y44TP", - "LN12xLfR1riFERGWD2ehTVXV2SgTPEGkda", - "Lb9MtyqceeU8myfnDjopRjtMNPDCdkdYNw", - "LWaf4t66rYFyrwevvSxXUUf1ghXeevwnrF", - "LXqk4DgW94Jj8Ne4w7TVS2cbrDGzFxZNxU", - "LhiFe4iNvqdFFySfCaKcz8LUTDtQsCpsW1", - "LPSSyTfct4eky7bdGaBTpdy3hztmMqMXFZ", - "LgcXMbZDRSE3EigmdkpPJJGrCzuEHVki5T", - "LPApGJ4vcYwJ3PqfrMcCstNjcZ3AZdJWoq", - "LNx8vM4HSHySnZS9FaLP7frerU1b5EXGsB", - "LQK4kcFKcGWJM6dGk2rjPS6SroH2HFfi1C", - "LPco5V5H2FRzqvE3RyA92XdkPKMvwJpNuP", - "LayMbyFpKVCtQ9nFccxcfhBya6qVQhfhbu", - "LiPzSX6U6Yn7EhGPwfEYdjhxyGnUtQQGJZ", - "LNxATzDKeGGt2EhyA8LwTEXoYjPv5iTXE8", - "LUYBZfFzXMNcMaFvgeQT58DgSYmf97zbMF", - "LMbym2ssdxWSdSQ3mbniW8cXhjigeXD5Kc", - "LhUTs87noKSP4TbPLcBddGmFSZpJNAwoqP", - "LSA2jhkmZgwZWBJfdWmdM86QkKAfrYMTP4", - "LYbWxSsJKzi24EGjz1i586XwickyAMBHEo", - "LL5U5BbErTwAxHkNhzgpPpknbgdutAbJfa", - "LSQ1RLD3PtazaMAi1uFte1f2iS5AhGmmTE", - "LMHMqXh1ksc2yU1L7kmSwhmVPLe6n3dWLC", - "LREYqFbYo1ZpmZHagSxGRsYneUJbkStZwG", - "Lf6jQQ2xXF87TsyqpYMPynTF39e4DpnsbS", - "LgGKHMPigMHGTnCFtRbma6nHEs52TDiZkR", - "LSyEah2pmQpKWHW1MsPkBUukv3JwbLFbp7", - "LPMwBp8Mb6o6332oYNF7V2BHxtPcB6xNvV", - "LUAzns7WnqjVRnqjh5Zu6XqjhbzYDbL8jL", - "LLheFGLodSJT7A45C7me94sp8BzcnbtU8X", - "LSXUFKZTicrw9LjdxE8v6RLyDgpaqEQXT8", - "LeUHN9Y1Qe6TdRX3nU7yLpEG1EKSQ7Bzvr", - "LKkw5wELgeaF371xzrUwYriJGu4Enpoe2A", - "LVnRUBuZJ4pWZMUJJevEZHaCqqZvKayZcj", - "LMZMDKE53tZa81MbHW61Lqnx8UcCFQrWdK", - "LS5meyubrb6gpnh7ScfANA5EA6TYE3hgAG", - "LaWf86KLNBi7xtonjoxNusr7dTYJM3Apsb", - "LdWAisbV4u7ufsvZc95AV6nbedkqcDSztz", - "LLewMMzTp9t6buyCxxN1GJKwviThwQuZF9", - "Ldscfk9QZhVgTxcTrpqpPfuh4d13PBk5LX", - "LcUkF9tHB2de1jvv9XWicqCSJH3boGuvrB", - "LVuh6heCKcHgQAdAcQeRa3BHNkYB2SN1sB", - "LbzcWGkCztHWSHrCWZGEK7gBwuCEYBS36F", - "LhTWof4eFXp8b9fiGXBNChVv7F6cCUqzcp", - "LbR3mpr1aYMsRBV35Rrd5tab6CSt1Kok88", - "LYN8VrfVT2KKNECnDXzMrmMsBXTKqT8qUD", - "LPdALGaG13ZDTyjos7ZzZnFKxwnoiq7Vvx", - "LLYNyWwRGLZtbhvVtwE9KaVgqNezmU834F", - "LRQgcZZDGA4ArbSY3t7dDMJQiCC7fm6Ngw", - "LNcWTb8vVNtVcdt8ePdVYtKuceywXGexnd", - "LfZygy36TzCJcLB15gj6iUPF2PqEKkqfjn", - "LY6b1KJ3PAoYEJG62FPsVygPr1Nt4dLxJu", - "LMpzSmo3xMdQu51gxD7AhiFfTdefjUGLcr", - "LePET2HXDooy9HV5XPCfyJb4dSz7btw7tc", - "LSF2ukWRZ6KzXTHYSdDPPVBGkE7QvpyCN3", - "LVNaL3VNted2bwyXkopvm3MSi7BeGNSgc5", - "LeJcMr4q6QqoV3ZTvi4uFxjvopd4Speu52", - "LX7LDTBPhr43pNmpALrVSpZuq3qp1WiDiW", - "LbGqdgDvKayibqwwWBUbc4f6HD8xkns36n", - "LL23kEmGYb1K3CRm1zLSjEfb5pHHBbh1qN", - "LWcz95wSBjuR3AGywLowEmFNFcuVvGU5Qr", - "LKs9Dy2fTeVGunYJXBEqdxxmxCxanG1KSM", - "LWAR63677sTMBh7CwGUgLF6xHrH83ybGgQ", - "LSURJv7fyEnibxuU5c9CCQpjZz4FyEVoTB", - "LeFrisey4bwdBXEPBZUnvsyhvp5R6HxjbN", - "LMhauutxdaTBvNBWt5Z3XVhGE72h56m3Y8", - "LhuS4qaCekfrsac8dW2fsD4pLQm9rf7AkU", - "LcQABUJC14zkbVKvgYKYAyvaDbcaEs6aio", - "LXPvebPoLED5cVyF5WtkVfX3wkXnQvtBFc", - "LQ6VBRdhtXYGsuJz8gn3TEJ94G7tRjvsnj", - "LTY8VWCMTLpkEwMu7GvXJe6Z9G6fytkJBQ", - "LRDR9HF7TmC3W7Z4hCFC8Frwou4qvs5MEG", - "LhGcqqfHd9VPDXVLjnkcisayWqZ7mMWExG", - "LcMMWNyc1MkwZzC896Yf8s3w54XEKjHJRk", - "LQMzwTguqtKaNxGCKQQBf3TG82UHwuhCet", - "LNcbiXmJH518LtazqSsYRNxzKSntJUrwxr", - "LMvJZzJAMShWmGhbqfbcYvFjjkkbjNBtuE", - "LRknefn3RKyVxwKkjdum9us6jerbRUUEYa", - "LSyXCZdZZ6JFQx2eFKzVsx8fCjajshKT2S", - "LMHGHtU5fasRisPz5Xd1qSZR5X1JpSME1S", - "LNodSYeXtiLWvakGXrP7wHsfu1veicrBp7", - "LhJXTJpxjUPV5yqqW9UsMzxM5dMa7WdVuE", - "LQ3GjdVVnbBoZmHGupZmNJYB1rdhACRm73", - "LbqpU58UNaUh6QptH4a325fEFHibXtisEi", - "LeLiUyNTXYV53rJzHBDL9MCeZwHcYg6oer", - "LQfCDij1hUNhUyQjmjEFiS3awGshk81W1j", - "LWES5WWwType47rjoqHTQ3cxhDtn7UKS17", - "LNe2VSdTEW5tWyCsHBtQi7ZEzvqwV6AC6F", - "LP4j37grd1NTRqdsNMVvPhH3YVPxRey6cd", - "LaN2VnxNMswM2gzJP5JBc49UUvNW2hQM2t", - "LZFVajmyg5S4GNmVxZ8w96uUoCuTk49SZb", - "LaCXjjfV7yvWFuoxq4MbaAA26HmDZ8ygnJ", - "LgKanQNSNYEKcx9mMUXGiZEe4sGvDsfEf2", - "LZyL3cG9DFZPShwPNrY5A7bFFC9REDz7nX", - "LUJMcEyxWi4H1t3VJm5p4MvtRNQwJMAkaq", - "LbJdtCU9BwuLadLQrsGw5LXQTw1cofCQUo", - "Lcozgv1ePhxZJmtPhXYUM4mAqBsgrixfJL", - "LT1nhGV3ugGUsuQ5S3P7hamuj8FG1XzXEP", - "LRnuLSyDwEBUwN8Zjv1iM9f2PdSrJaXkiv", - "LXDfRLDT4aSaD59HBFiDLVoDsKzbKHvEG7", - "LYNKSRZU5NzsJ91k8F5d8w9n3wTXRmESco", - "LT7wT3iqV9VBfQUyETdc22YgmuYPpsz92L", - "LR5NkVi57qqNmxBTgNXpaLMyF2R1rSWfdv", - "LR7vwew7NMGS5TwY7nmxjENY3Tw1pAEpBn", - "LRQY6WqBwZPXH6L4HXQSZCLuPZKLoR2A37", - "LPjTYuZumxSp9L8o1PZvpnNJ12iiDrJzpF", - "LchWtmeyR2sSPioQ5uDvj5zHqgRiPuSnx5", - "LR3TKpkSdQLpc9uBfJJhshkc5YQWaeXzzA", - "Lf6kkRPHqxXPtURWyhV3NrSMQhvJxHeFMM", - "Lh3hMVpf5cW5iMs1a5ZSH7wamdj7Cu7A4J", - "Lh1d9VCvuJTH7zankijKBQCHT2TBTS84rQ", - "Ld8GhZGMzsvWrrrLRdt1vK4jQ17swUKVc6", - "LUZnNT1Yxu5weCP1xYmUQoXxJ2yJjNwvyB", - "LYuvKNFjNhXUd4PxRfwBkB4mLwt8SwM1og", - "LaR2Zgy44XV3ZfqwjjL9ZBXJjMdL78LKC5", - "LZssXv5uSuictUyhXtXFmPJT3i7r8WTaFd", - "LbVLwA5kueFzQtCvjLWZcicSACTwfpt8pK", - "Li6QhA1JwQRdooVYWQmqNY7ajapPNiEYpV", - "LeZLuU9H2YoSUcnf8oFZCoGfsnv1PpScgA", - "LZCwXzfqSZWSqUHhZ8ZvQDRA54TdwHkzm4", - "Lfw2PmMhY1s9kSmfGid6wRY4KeswmuT1mT", - "LgZHB8N4sj1ajAqntXEvfZC46pCVfpNWXC", - "LT9CRh2GCidjVSB9yDzhAYR9o29JMzFV52", - "LhApBvac5mtAoeRhyeM1UHBAKD6t5o337g", - "LffTmaekgdvzKBZarsTzQoooictqjyRfZ9", - "LhGbWvFi46d4RmuGZZDedbpeK5cpmDVKjK", - "LaDBbkQG2eg3oTGcJJcUbSqYujEWD8GxGV", - "LSBeVyUmAomavuAaM3NRAcWqBJSCMZkma3", - "LaArmQWVYATX1RqLfcHTuiEKPy1uAUK3So", - "LaqS7mm5khUqsMcoH29pcaxJnMTZy4h3VP", - "LPV77htAh4RvvJnK2poWuBJA1XKU5UuYn6", - "LbtB14P8bSDL7BBS5u9C7QzfSbLXSMuWQ6", - "LeTFdUsPNG9DpFzmxqdNzcRXWiUhVBFSje", - "Lap9Zp3RjjiasRKZ6HVh9PrrxTnbEzmQQ1", - "LRJFh8Gem1G27ZER5RDqjaEqMknkDAnL9H", - "LXPdXPJ2ejvQEUuKii2XmKpcwWGKReQ1AF", - "LV4EBa9dDLAVrnoP1ZJ9uPZ21QSnGsyng5", - "LMTQpVaabmQ4GJRhya5UdDbBYwCnnrw9j2", - "LhomQHA6tTsLcPEmm9XyDBKTUvmZPEGCw2", - "LV6zkyyQ4w6MTstKHGUTvp147qF6tDK4Hv", - "LZ7JjSm2ipzdDSS67TcDz6ypYB4uRRh5qb", - "LKmkBiyWJcrk2qsGpDLSpENU9VmFUhTFLb", - "LcfSw6juAquu7ELptLuWdGVkFeoCGBt7TT", - "LVVeiEtgxcAmZNR5ZS7EkDEuP1rSQ2JR2d", - "LdggQFKWBZWXBEhmXAa4jVqPvV626maE4A", - "Lb2Uf2AJHvMg5gHebHEnhEhLRnjtZ3AKKb", - "Ld9zAq3tJ3KfxcXoXPXmKPS3R5yzrcD1zt", - "LM9B7S8wn1oXUnPpYXPjQeWufnkNYbvXwb", - "LMRdhEeegHTXXwBSEmv3Gzc1fQ9m4bR7wH", - "LdhYdFtLHfnWrnBFCcXirMy6BUr8mapYxT", - "LWLCjJqo4M1XbYQSKrXfhfoUB4pZxMQsTT", - "LYM1ejQWpBhdkbEhmfJrQ2KXFyeB5U9uG4", - "LKkDW2qjDbR2aumXuWysSUxhPsyKsLYLLQ", - "LSiQ7Evxz4KmEpaWoMFsb4in99iiZqchXi", - "LgFdXHkjNNuRsnjvUFByj2y8RFKBwigRvv", - "LgmZeoFz3LFw3M6iH666sXs4efPPPFDFRn", - "LRwVbxqon6hhg4uKFFjsPdsRBcb6CuuY8N", - "LTW9LQ4m3jvpaJQqG9SywG2YGxTxc95XDS", - "LhHYXKgU4ediDQQiVKgFcDss1Faxj3dCwT", - "LhkNzhtVPqyTiM94eDT5jAGpk6rrjBWySD", - "LYQAfktqsZm9UXFT8Gbq7hxQHLXLvvY9Vw", - "LSWtD79xfEKQie9xkYpLq7GtgPRCosKtqr", - "LTfbfSNWmwayq8XcjVVvQ7L6RRv4YqhnXb", - "LcssJQC1XYDMTibztCZQmEk56tefgHWceJ", - "LWr6QzQCSbgCmfmiMXRPEDt3RjQL1b1h6v", - "LexyJFNrgcNnnFL1ty5mWvxbLhJvPQr6Ps", - "LPbSJPvBLwAsFSARdt1zEi8maLMmEG6su4", - "LcJUVq96kyxh4vYjQ2V1mLbRLad4oxckPu", - "LL7sb6UWoGaXZ3Q6Y81sff5ASdW7ziSZgC", - "LXP1QYfCQRdA4CdnUjUJdMxoK3RPCCV3G5", - "LRr5KW4SK2APodwB73UmJWzMJYKk5edKCb", - "LSZjBdRuru46fg8KT6eubsHPfibmbQNgSk", - "LVnGQcnFjRqXWKocqFycqthhejNrDUb6ns", - "Lfmjw7wfBTtKYxeF6yYcg9VFp4yyoCQzVQ", - "LTeFKRndXEA3DDjVENvqmwFFWYarRtScxb", - "LdcV6oRJgYv9Qc52JkYCepnA5pW923Fygh", - "LZ7BP6uy6VcbXa5vYeQwpiCHHADi7R3QE4", - "LhkatG3dDHy85RFmEpKuRv88YHJL7tx11e", - "Li9n2e2GYndUMgjT8YarNmptsqyy57nBCn", - "LLwKLVEKMse8WbFU3t5m6oqeq6yQzv6Vwm", - "LYLEJH4tTtDECUWTRkv2JNgGp2qnC83eS6", - "LPGBJqe867xF4tjgQwknzoVU6v2XpggdaS", - "Li1TcaUhEP4tkbudkWncfVCY1j1wMgPnS6", - "Lbg2zTeZBjm3pN4c2cmJ7Aa83GNgJPP5rk", - "LRYV3RzHidi7vSYqyGTco2cLuTvkKWjDZT", - "Ldv5gad84XcH6AntbxjGxZHzcygCzgsi3G", - "LQ3ghptDNPhd8Cf5N7kBrvTbd6wAnfVTqy", - "LX9UGoNbTQRQmfymnXvqLKgbTqPofo2ZQe", - "LSTizhmpK1xbmAB6ug729uLs5xp8dir99o", - "LaXB6PT7Ur5PfSeKQtizmeaQzfKf9ULHeo", - "Li24gKAxQGHhQMcTNGVFMPikKoGu5HoQNj", - "LZ2mXGuvoZJTvtYxsupvNdjMrUEfFVcs8b", - "LNWTCA42visTpGMMmKMs8EBG5P5QFYwmXw", - "LRMYix9UCn76pmB87daetkypi9rEA9BkcK", - "LZBUZ9WpmCr1ki5tWED6LPRgKtUqi6t3EG", - "LKcGMQghBrnAutuyB9oZryJzyxhnoW5qyA", - "LgNtTi7jnv2T7nVLtiLs7MXc9fUcZCM1PX", - "LRtL2QCJ885ndQRYcCyxHtohojNtQ3kwms", - "Lb3Q8crTsgPJVhF8hGocPadUG8L4Ko7Uk5", - "LVRgQ7MYCVQkGN96mywZDC7X4wVKnVScXL", - "LgFC8Azpm1QQg4zf1stvyhdmk1J3GKRXgg", - "LZyaVaUVx1S4R9RtvVPHmirBCxwYy5BLqy", - "LWqHKipQvgBJXFH1YY9Aqyvidz3JLsbNqu", - "LS4EQ4HUyMj15S7uh2owumLWarpMwzYWf2", - "LeRNyZ4chbEqkuDtfSqC47r6aMDnR1Gi72", - "LeVbPnsv8Kys7dcRaY3yg9infzmz7G9Hpo", - "LUEVtdDJ8X8jVdNiUEX1xvzVQxcsSZPkrz", - "LMas9g21ce1eyXByhr7Ngaygbfo45Q8N4Q", - "LiEwXax16TsBmJpjTe1w332TeyUSamnvvA", - "LeGQnoVFNRWT3tUJFo8SToE9KYy8Px939K", - "Lfq1tZYPSa6LJMWMCzEfPVjyMjnHAUPnwj", - "LhiG7enkC7hUoRVyDset6nG3jBmFAnxP76", - "LhbLG3xjaFjA16LxiWKiA3yz1UPuam2LiA", - "LgafdEoGQynfHUJZGHLVaQcrr69sHjDjVB", - "LWh4HzjkKUMvQYCmyEuiLhwEnzyedFGgRD", - "LhmNnMErWRvw9pBhEaGA1h71iBrxBe91Ef", - "LZdBrWEZYwevQWw5N7cfx4Hp2716tWEo2y", - "LYqypgTC2U1H7e4mWtmDQXEJuokF5SAcLB", - "LQF3JiSeVnhARcgCJHSNvzdqRvmJGsuzvL", - "LURqMivxZfqtWHaydfLtsUwpd76pjxBuke", - "LYvQksgCo4c9pR1id6dbKDwHPEETYMZXuf", - "LREa1oKedRNis7T7XJ3cNq9tBAXCtdcGiU", - "Ldk6o44cz6YxFNvmvYhhPficYgmLe5hT6d", - "Lbx8PM2ss9DcPYMmzZqFcyjyFaLJM6Cd4Q", - "Lc7TBKGbvX9HNsZGyhWWrXGCLzpEZ7cEdX", - "LQfigrdhHAVV26uNS3hCVRxJZtDxe3eVV6", - "LZLynLMfUvPVMAmNXF46LKeA8jfUmUBKxe", - "LWnKVg3QJta6oyMVNLo6htofgyRfupkA7Z", - "LKHnGKbzJX7e24QJL98f6ABtuG4MQjAX4Z", - "LKexRP5HvwdBLSxLG5FnB3QVXZqNqARFiK", - "LgMa3Az7WKhMYcA9HB9abzkZysdkihHy5p", - "LMQybwAsWNJCLhVukHdntHCZ4R6K7nqZJJ", - "LNEbx9Q5mvnkUdKKqBBjm3bVbvTKfBLm7U", - "LL73GoiYM6pz2RVMDLWDAQhyjf2QtEojsj", - "Lfzvumvksk5EyUTKiFJAcKubAUDq7iiDLR", - "LQfuDpy9FtxDuwH8KvV6v8U6CDEmLPCtLq", - "LXAwyHXJ5uXkEtJr8eERwWVDWmiTwZjDXZ", - "Lbh4tQwuELbkx5MSoNgeHPpLgkYgzwBqmN", - "LL8Sr3Pm1DMLSgjgE2tzTgaPsBn4tUBZMf", - "LaTtLUvoWMvjDgUtiu25HFcTXrq1B4ZZiR", - "LhhaJPMJGCK9zfoRqZV4FdvrqHrfBuZRtT", - "LQCko1GY68nozg5TSDYBMvTgah19GUdy9v", - "LiMQxVrAutJDdUoJYMn4HxPJU6MUJo7Wih", - "LTHE6Thf2TD3apc2k8bGe49uT8vk7Duumk", - "LN4HkCAKxkuQTd1ZxxG3MdUZL7h9956sLD", - "LggbiEPxm9q7qujBgH8VowJDz462EeocVe", - "Lai1PeadaURQGssF7wCJVkUCkMKVCct7xt", - "LSpBCFpxVR6rHNmQPF2Emj2biJ8jLHkwRW", - "LdBpRFJECJfRMbvXL1Jp6qVhpkR6orzeUt", - "LKN3hdMPteWLEXBQxi5pwXdeufFa83FwgN", - "LVJvfkYtMNKUdME8zmMAkNCnK8CPgx9hS3", - "LWfJsaBmH9ppZYP4q2tCtUmqe178f1HHw5", - "LMKE317FWXkzDMCTptsvPxHfCWSPsD2rDJ", - "LVsxLvnbCw2Veirdkm57WwJ1g6PezjQc63", - "LXLmZvvK19RiNLxUhAfmWbjQEjDr67bNyV", - "LPTrg77VfLw6sGa5FH31E8C9iepsxNzzDk", - "LLzSdkRuTkSBFVSjJjjMQiKrcMuCTsA28x", - "LfaJR6MJRJ6P8xoZK5bYo8ee2oZUETfeGu", - "LdRrMKNj4qLNpMXLbM53rLxAepF4baTpLe", - "LULuUSgDKn83sESXpTPZ2pMYkR4JVjQaBJ", - "LdoTfSDBuH83aCvWtL9sHwiFvUTXbYwf5Q", - "LfV2DPCW5m2WMGBGMhLUv8bqq4BwN37JP6", - "LXhYvad9n65E9FNeSGwqi7jHMQQnc1gKiQ", - "LMCDQqLdsHiXBwe6Qr5vmMnkg1M8TYU5XN", - "LiSTM5jNER3o6G1uTDosw5kVM1XvRBaY7g", - "LgRrD1BXY3JydbAiBz7DfdcurjnLpHyXJu", - "LPiPe2TxtCzs3gsiGK89urizhyRsd14j5E", - "LUbTszmejtBwnHMWTqofAvTAGSnUcyZhK8", - "LVgVwUBwP45kBjQLq5h221WYqqQbeLmLrY", - "LVqE67xxpsqahokAGYrnCbXEvvUdNgZhwe", - "LXw9igkHBKPN58f9UXRK1CAqoJSFXPr8yu", - "LhkjGCFtV1LbdisVmruFZnC6TdaXT1iD1y", - "LaQEaTTBKk8VudnfpGKKLt8fwu4UNDK7Fe" - )); - } -} diff --git a/src/test/java/WalletHelperFeeTest.java b/src/test/java/WalletHelperFeeTest.java new file mode 100644 index 0000000..c7d11f9 --- /dev/null +++ b/src/test/java/WalletHelperFeeTest.java @@ -0,0 +1,214 @@ +import io.xlite.daemon.app.net.protocols.bitcoin.BitcoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetNetworkParameters; +import io.xlite.daemon.app.net.protocols.dashcoin.DashcoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.digibyte.DigibyteNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.dogecoin.DogecoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.litecoin.LitecoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.pivx.PivxNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.pocketcoin.PocketcoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.ravencoin.RavencoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.syscoin.SyscoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.unobtanium.UnobtaniumNetworkParametersLegacy; +import io.xlite.daemon.app.wallet.WalletHelper; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.params.TestNet3Params; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class WalletHelperFeeTest extends TestHelper { + + @BeforeEach + void setup() { + commonSetup(); + } + + @AfterAll + static void cleanup() { + commonCleanup(); + } + + // ======================================================================== + // Happy Path - getFeePerByte() tests + // ======================================================================== + + @Test + void testGetFeePerByte_Bitcoin() { + BitcoinNetworkParametersLegacy params = new BitcoinNetworkParametersLegacy(); + assertEquals(60L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Litecoin() { + LitecoinNetworkParametersLegacy params = new LitecoinNetworkParametersLegacy(); + assertEquals(10L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Blocknet() { + BlocknetNetworkParameters params = new BlocknetNetworkParameters(); + assertEquals(20L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Dashcoin() { + DashcoinNetworkParametersLegacy params = new DashcoinNetworkParametersLegacy(); + assertEquals(5L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Digibyte() { + DigibyteNetworkParametersLegacy params = new DigibyteNetworkParametersLegacy(); + assertEquals(200L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Dogecoin() { + DogecoinNetworkParametersLegacy params = new DogecoinNetworkParametersLegacy(); + assertEquals(2500L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Syscoin() { + SyscoinNetworkParametersLegacy params = new SyscoinNetworkParametersLegacy(); + assertEquals(40L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Pivx() { + PivxNetworkParametersLegacy params = new PivxNetworkParametersLegacy(); + assertEquals(20L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Unobtanium() { + UnobtaniumNetworkParametersLegacy params = new UnobtaniumNetworkParametersLegacy(); + assertEquals(3L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Pocketcoin() { + PocketcoinNetworkParametersLegacy params = new PocketcoinNetworkParametersLegacy(); + assertEquals(20L, WalletHelper.getFeePerByte(params)); + } + + @Test + void testGetFeePerByte_Ravencoin() { + RavencoinNetworkParametersLegacy params = new RavencoinNetworkParametersLegacy(); + assertEquals(1000L, WalletHelper.getFeePerByte(params)); + } + + // ======================================================================== + // Happy Path - getMinTxFee() tests + // ======================================================================== + + @Test + void testGetMinTxFee_Bitcoin() { + BitcoinNetworkParametersLegacy params = new BitcoinNetworkParametersLegacy(); + assertEquals(12000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Litecoin() { + LitecoinNetworkParametersLegacy params = new LitecoinNetworkParametersLegacy(); + assertEquals(5000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Blocknet() { + BlocknetNetworkParameters params = new BlocknetNetworkParameters(); + assertEquals(10000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Dashcoin() { + DashcoinNetworkParametersLegacy params = new DashcoinNetworkParametersLegacy(); + assertEquals(2500L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Digibyte() { + DigibyteNetworkParametersLegacy params = new DigibyteNetworkParametersLegacy(); + assertEquals(100000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Dogecoin() { + DogecoinNetworkParametersLegacy params = new DogecoinNetworkParametersLegacy(); + assertEquals(225000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Syscoin() { + SyscoinNetworkParametersLegacy params = new SyscoinNetworkParametersLegacy(); + assertEquals(20000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Pivx() { + PivxNetworkParametersLegacy params = new PivxNetworkParametersLegacy(); + assertEquals(10000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Unobtanium() { + UnobtaniumNetworkParametersLegacy params = new UnobtaniumNetworkParametersLegacy(); + assertEquals(1000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Pocketcoin() { + PocketcoinNetworkParametersLegacy params = new PocketcoinNetworkParametersLegacy(); + assertEquals(10000L, WalletHelper.getMinTxFee(params)); + } + + @Test + void testGetMinTxFee_Ravencoin() { + RavencoinNetworkParametersLegacy params = new RavencoinNetworkParametersLegacy(); + assertEquals(100000L, WalletHelper.getMinTxFee(params)); + } + + // ======================================================================== + // Generic path — fee values must match loaded configs + // ======================================================================== + + @Test + void testGenericFeesMatchLoadedConfigs() { + // Simple in-memory set — validates logic without filesystem + java.util.Map cfgs = + io.xlite.daemon.app.coinconfig.CoinConfigRegistry.list(); + org.junit.jupiter.api.Assertions.assertFalse(cfgs.isEmpty(), "registry empty — TestHelper must load LTC/BLOCK"); + for (java.util.Map.Entry e : cfgs.entrySet()) { + String ticker = e.getKey(); + io.xlite.daemon.app.coinconfig.CoinConfig cfg = e.getValue(); + // Only check migrated tickers that WalletHelper knows via HasFeeParams + if (!io.xlite.daemon.app.coinconfig.CompiledCoinSupplement.supports(ticker)) + continue; + io.xlite.daemon.app.coinconfig.ConfigurableNetworkParameters generic = + io.xlite.daemon.app.coinconfig.ConfigurableNetworkParameters.from(cfg); + assertEquals(cfg.feePerByte(), WalletHelper.getFeePerByte(generic), + ticker + " generic feePerByte must equal config"); + assertEquals(cfg.minTxFee(), WalletHelper.getMinTxFee(generic), + ticker + " generic minTxFee must equal config"); + } + } + // ======================================================================== + // Edge Case - unknown coin returns default + // ======================================================================== + + @Test + void testGetFeePerByte_UnknownCoin_Throws() { + NetworkParameters unknownParams = TestNet3Params.get(); + assertThrows(RuntimeException.class, () -> WalletHelper.getFeePerByte(unknownParams)); + } + + + @Test + void testGetMinTxFee_UnknownCoin_Throws() { + NetworkParameters unknownParams = TestNet3Params.get(); + assertThrows(RuntimeException.class, () -> WalletHelper.getMinTxFee(unknownParams)); + } +} \ No newline at end of file diff --git a/src/test/java/io/xlite/daemon/app/AppTest.java b/src/test/java/io/xlite/daemon/app/AppTest.java new file mode 100644 index 0000000..dfe10b9 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/AppTest.java @@ -0,0 +1,129 @@ +package io.xlite.daemon.app; + +import io.xlite.daemon.app.util.ConfigHelper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Precedence matrix for {@link App#resolveUserConfigDir(String, String, String, String)}. + *

{@code XLITE_DATA_HOME} (non-blank, trimmed, absolute-normalized) must win over + * every per-OS default; blank/null env must fall through to the platform root.

+ */ +class AppTest { + + private static final String USER_HOME = "/home/tester"; + private static final String APPDATA = "C:\\Users\\tester\\AppData\\Roaming"; + + @TempDir + static Path sandbox; + private static String savedConfigDir; + + /** + * Isolate from the real home dir: touching {@code App} triggers class + * init, which builds live objects against {@code CONFIG_DIR}. Without + * this, the suite reads (and previously even migrated) the developer's + * real {@code ~/.config}. + */ + @BeforeAll + static void isolateConfigDir() { + savedConfigDir = ConfigHelper.CONFIG_DIR; + ConfigHelper.CONFIG_DIR = sandbox.toString(); + } + + @AfterAll + static void restoreConfigDir() { + ConfigHelper.CONFIG_DIR = savedConfigDir; + } + + @Test + @DisplayName("XLITE_DATA_HOME overrides the linux default") + void testResolve_EnvOverrideWinsOnLinux() { + assertEquals("/tmp/xlite-sandbox/data", + App.resolveUserConfigDir("/tmp/xlite-sandbox/data", "Linux", USER_HOME, APPDATA)); + } + + @Test + @DisplayName("XLITE_DATA_HOME overrides the windows default") + void testResolve_EnvOverrideWinsOnWindows() { + // expectation built through File so the assertion holds on any host OS + File expected = new File("/tmp/xlite-sandbox/data").getAbsoluteFile(); + assertEquals(expected.getPath(), + App.resolveUserConfigDir("/tmp/xlite-sandbox/data", "Windows 11", USER_HOME, APPDATA)); + } + + @Test + @DisplayName("XLITE_DATA_HOME overrides the mac default") + void testResolve_EnvOverrideWinsOnMac() { + File expected = new File("/tmp/xlite-sandbox/data").getAbsoluteFile(); + assertEquals(expected.getPath(), + App.resolveUserConfigDir("/tmp/xlite-sandbox/data", "Mac OS X", USER_HOME, null)); + } + + @Test + @DisplayName("blank or null env falls back to per-OS defaults (real-world os.name casing)") + void testResolve_BlankEnvFallsBackToPlatformDefaults() { + for (String blank : new String[]{null, "", " "}) { + assertEquals(USER_HOME + File.separator + ".config", + App.resolveUserConfigDir(blank, "Linux", USER_HOME, APPDATA)); + assertEquals(APPDATA, + App.resolveUserConfigDir(blank, "Windows 11", USER_HOME, APPDATA)); + assertEquals(USER_HOME + File.separator + "Library" + File.separator + "Application Support", + App.resolveUserConfigDir(blank, "Mac OS X", USER_HOME, null)); + } + } + + @Test + @DisplayName("unknown os.name falls back to the linux-style default") + void testResolve_UnknownOsFallsBackToDotConfig() { + assertEquals(USER_HOME + File.separator + ".config", + App.resolveUserConfigDir(null, "sunos", USER_HOME, APPDATA)); + } + + @Test + @DisplayName("padded override is trimmed before resolution") + void testResolve_PaddedEnvIsTrimmed() { + File expected = new File("/tmp/xlite-sandbox/data").getAbsoluteFile(); + assertEquals(expected.getPath(), + App.resolveUserConfigDir(" /tmp/xlite-sandbox/data ", "Linux", USER_HOME, APPDATA)); + } + + @Test + @DisplayName("relative override is normalized against the working directory") + void testResolve_RelativeEnvNormalizedToAbsolute() { + String resolved = App.resolveUserConfigDir("relative/sandbox", "Linux", USER_HOME, APPDATA); + assertTrue(resolved.startsWith(new File("").getAbsolutePath()), + "must resolve under the working directory, got: " + resolved); + assertTrue(resolved.endsWith("relative" + File.separator + "sandbox"), + "must keep the relative segments, got: " + resolved); + } + + @Test + @DisplayName("windows default without AppData fails fast instead of yielding a null dir") + void testResolve_WindowsDefaultWithoutAppDataThrows() { + for (String blank : new String[]{null, "", " "}) { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> App.resolveUserConfigDir(null, "Windows 11", USER_HOME, blank)); + assertTrue(e.getMessage().contains("XLITE_DATA_HOME"), + "message must point at the override, got: " + e.getMessage()); + } + } + + @Test + @DisplayName("mac/linux default without user.home fails fast instead of yielding a null dir") + void testResolve_NonWindowsDefaultWithoutUserHomeThrows() { + for (String blank : new String[]{null, "", " "}) { + assertThrows(IllegalStateException.class, + () -> App.resolveUserConfigDir(null, "Linux", blank, APPDATA)); + assertThrows(IllegalStateException.class, + () -> App.resolveUserConfigDir(null, "Mac OS X", blank, null)); + } + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigCacheTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigCacheTest.java new file mode 100644 index 0000000..813c7ae --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigCacheTest.java @@ -0,0 +1,50 @@ +package io.xlite.daemon.app.coinconfig; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Byte-level tests for {@link CoinConfigCache} — no network, no parsing. + */ +class CoinConfigCacheTest { + + @Test + void testRoundTrip(@TempDir Path root) { + CoinConfigCache cache = CoinConfigCache.forSource(root, "https://example.invalid/a"); + assertTrue(cache.cachedManifest().isEmpty()); + assertTrue(cache.cachedConf("ltc.conf").isEmpty()); + cache.storeManifest("[{\"ticker\":\"LTC\"}]"); + cache.storeConf("ltc.conf", "[LTC]\nTitle=Litecoin\n"); + assertEquals("[{\"ticker\":\"LTC\"}]", cache.cachedManifest().orElseThrow(AssertionError::new)); + assertEquals("[LTC]\nTitle=Litecoin\n", cache.cachedConf("ltc.conf").orElseThrow(AssertionError::new)); + assertEquals("manifest-latest.json", cache.dir().resolve("manifest-latest.json").getFileName().toString()); + } + + @Test + void testSourcesAreNamespaced(@TempDir Path root) { + Path a = CoinConfigCache.forSource(root, "https://example.invalid/a").dir(); + Path b = CoinConfigCache.forSource(root, "https://example.invalid/b").dir(); + assertNotEquals(a, b); + assertEquals(a, CoinConfigCache.forSource(root, "https://example.invalid/a").dir()); + } + + @Test + void testPruneKeepsCurrentPlusMostRecent(@TempDir Path root) throws Exception { + Path oldA = root.resolve("src-old-a"); + Path oldB = root.resolve("src-old-b"); + Files.createDirectories(oldA); + Files.createDirectories(oldB); + Files.setLastModifiedTime(oldA, FileTime.fromMillis(1000)); + Files.setLastModifiedTime(oldB, FileTime.fromMillis(2000)); + CoinConfigCache current = CoinConfigCache.forSource(root, "https://example.invalid/new"); + assertTrue(Files.isDirectory(current.dir())); + assertTrue(Files.isDirectory(oldB), "most recent other source kept"); + assertFalse(Files.exists(oldA), "oldest source pruned"); + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigRegistryFallbackTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigRegistryFallbackTest.java new file mode 100644 index 0000000..643b49e --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigRegistryFallbackTest.java @@ -0,0 +1,83 @@ +package io.xlite.daemon.app.coinconfig; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Offline-fallback tests for {@link CoinConfigRegistry#loadWithFallback}. + * Upstream failure is simulated with a closed localhost port (fast + * refusal, no network dependency). + */ +class CoinConfigRegistryFallbackTest { + + private static final String DEAD_UPSTREAM = "http://127.0.0.1:9/bcf"; + + private static final String MANIFEST = + "[{\"blockchain\":\"Litecoin\",\"ticker\":\"LTC\",\"ver_id\":\"litecoin--v1\",\"xbridge_conf\":\"litecoin--v1.conf\"}," + + "{\"blockchain\":\"Dogecoin\",\"ticker\":\"DOGE\",\"ver_id\":\"doge--v1\",\"xbridge_conf\":\"doge--v1.conf\"}]"; + private static final String LTC_CONF = + "[LTC]\nTitle=Litecoin\nAddressPrefix=48\nScriptPrefix=50\nSecretPrefix=176\nCOIN=100000000\nFeePerByte=10\nMinTxFee=5000\nPort=9332\n"; + private static final String DOGE_CONF = + "[DOGE]\nTitle=Dogecoin\nAddressPrefix=30\nScriptPrefix=22\nSecretPrefix=158\nCOIN=100000000\nFeePerByte=10\nMinTxFee=1000\nPort=22555\n"; + + @BeforeEach + void reset() { + CoinConfigRegistry.resetForTest(); + } + + @AfterEach + void resetAfter() { + CoinConfigRegistry.resetForTest(); + } + + private static CoinConfigCache warmCache(Path root, String source) { + CoinConfigCache cache = CoinConfigCache.forSource(root, source); + cache.storeManifest(MANIFEST); + cache.storeConf("litecoin--v1.conf", LTC_CONF); + cache.storeConf("doge--v1.conf", DOGE_CONF); + return cache; + } + + @Test + void testWarmCacheServedWhenUpstreamDead(@TempDir Path root) { + CoinConfigCache cache = warmCache(root, DEAD_UPSTREAM); + CoinConfigRegistry.loadWithFallback(DEAD_UPSTREAM, cache); + Map all = CoinConfigRegistry.list(); + assertEquals(2, all.size()); + assertEquals(48, all.get("LTC").addressPrefix()); + assertEquals(cache.dir().toString(), CoinConfigRegistry.loadedSource()); + } + + @Test + void testColdCachePropagatesOriginalFailure(@TempDir Path root) { + CoinConfigCache cache = CoinConfigCache.forSource(root, DEAD_UPSTREAM); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> CoinConfigRegistry.loadWithFallback(DEAD_UPSTREAM, cache)); + assertTrue(e.getMessage().contains("Cannot fetch"), e.getMessage()); + assertTrue(CoinConfigRegistry.list().isEmpty()); + } + + @Test + void testNullCachePropagatesFailure(@TempDir Path root) { + assertThrows(IllegalStateException.class, + () -> CoinConfigRegistry.loadWithFallback(DEAD_UPSTREAM, null)); + } + + @Test + void testHealthyLoadNeedsNoFallback(@TempDir Path dir) throws Exception { + Files.createDirectories(dir.resolve("xbridge-confs")); + Files.writeString(dir.resolve("manifest-latest.json"), MANIFEST); + Files.writeString(dir.resolve("xbridge-confs").resolve("litecoin--v1.conf"), LTC_CONF); + Files.writeString(dir.resolve("xbridge-confs").resolve("doge--v1.conf"), DOGE_CONF); + CoinConfigRegistry.loadWithFallback(dir.toString(), null); + assertEquals(2, CoinConfigRegistry.list().size()); + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigRpcTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigRpcTest.java new file mode 100644 index 0000000..dbd6d6b --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigRpcTest.java @@ -0,0 +1,117 @@ +package io.xlite.daemon.app.coinconfig; + +import io.xlite.daemon.app.net.api.http.master.HTTPServerHandler; +import io.xlite.daemon.app.util.ConfigHelper; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class CoinConfigRpcTest { + + private String savedConfigDir; + + private static Map simpleCfgs() { + Map m = new LinkedHashMap<>(); + Map ltc = new LinkedHashMap<>(); + ltc.put("AddressPrefix", "48"); ltc.put("ScriptPrefix", "50"); ltc.put("SecretPrefix", "176"); + ltc.put("COIN", "100000000"); ltc.put("FeePerByte", "10"); ltc.put("MinTxFee", "5000"); + ltc.put("Port", "9332"); ltc.put("DustAmount", "0"); + m.put("LTC", new CoinConfig("LTC", "Litecoin", "litecoin--v0.21.1", ltc)); + Map block = new LinkedHashMap<>(); + block.put("AddressPrefix", "26"); block.put("ScriptPrefix", "28"); block.put("SecretPrefix", "154"); + block.put("COIN", "100000000"); block.put("FeePerByte", "20"); block.put("MinTxFee", "10000"); + block.put("Port", "41414"); block.put("DustAmount", "0"); + m.put("BLOCK", new CoinConfig("BLOCK", "Blocknet", "blocknet--v4.2.0", block)); + return m; + } + + @BeforeEach + void reset() { + savedConfigDir = ConfigHelper.CONFIG_DIR; + CoinConfigRegistry.resetForTest(); + } + + @AfterEach + void resetAfter() { + CoinConfigRegistry.resetForTest(); + ConfigHelper.CONFIG_DIR = savedConfigDir; + } + + private JsonObject call(String method, JsonArray params) { + HTTPServerHandler h = new HTTPServerHandler(); + return h.getResponse(method, params); + } + + @Test + public void testNotLoadedReturnsMinusOne(@TempDir Path tmp) { + ConfigHelper.CONFIG_DIR = tmp.toString(); + JsonObject resp = call("getCoins", new JsonArray()); + assertEquals(-1, resp.getAsJsonObject("error").get("code").getAsInt()); + assertTrue(resp.get("result").isJsonNull()); + } + + @Test + public void testReturnsFullDtoSorted(@TempDir Path tmp) { + ConfigHelper.CONFIG_DIR = tmp.toString(); + CoinConfigRegistry.loadForTest(simpleCfgs()); + JsonObject resp = call("getCoins", new JsonArray()); + assertTrue(resp.get("error").isJsonNull()); + JsonArray arr = resp.getAsJsonArray("result"); + assertFalse(arr.isEmpty()); + assertEquals(2, arr.size()); + String prev = ""; + for (int i = 0; i < arr.size(); i++) { + JsonObject o = arr.get(i).getAsJsonObject(); + for (String k : new String[]{"ticker","blockchain","verId","addressPrefix","scriptPrefix","secretPrefix","coin","feePerByte","minTxFee","port","dustAmount"}) + assertTrue(o.has(k), "missing " + k); + if (!o.get("dustAmount").isJsonNull()) + assertTrue(o.get("dustAmount").getAsLong() >= 0); + String t = o.get("ticker").getAsString(); + assertTrue(t.compareTo(prev) >= 0, "not sorted " + prev + ">" + t); + prev = t; + } + JsonObject ltc = null; + for (int i = 0; i < arr.size(); i++) if ("LTC".equals(arr.get(i).getAsJsonObject().get("ticker").getAsString())) ltc = arr.get(i).getAsJsonObject(); + assertNotNull(ltc); + assertEquals(48, ltc.get("addressPrefix").getAsInt()); + assertEquals(50, ltc.get("scriptPrefix").getAsInt()); + assertEquals(176, ltc.get("secretPrefix").getAsInt()); + assertEquals(100000000, ltc.get("coin").getAsLong()); + assertEquals(10, ltc.get("feePerByte").getAsLong()); + assertEquals(5000, ltc.get("minTxFee").getAsLong()); + assertEquals(9332, ltc.get("port").getAsInt()); + assertTrue(ltc.get("dustAmount").isJsonNull(), "interim bcf 0 placeholder maps to null until bcf populated"); + } + + @Test + public void testAliasListCoinsDeepEquality(@TempDir Path tmp) { + ConfigHelper.CONFIG_DIR = tmp.toString(); + CoinConfigRegistry.loadForTest(simpleCfgs()); + JsonArray a = call("listCoins", new JsonArray()).getAsJsonArray("result"); + JsonArray b = call("getCoins", new JsonArray()).getAsJsonArray("result"); + assertEquals(a.toString(), b.toString()); + } + + @Test + public void testCaseInsensitive(@TempDir Path tmp) { + ConfigHelper.CONFIG_DIR = tmp.toString(); + CoinConfigRegistry.loadForTest(simpleCfgs()); + assertTrue(call("GETCOINS", new JsonArray()).get("error").isJsonNull()); + assertTrue(call("ListCoins", new JsonArray()).get("error").isJsonNull()); + } + + @Test + public void testRejectsParams(@TempDir Path tmp) { + ConfigHelper.CONFIG_DIR = tmp.toString(); + CoinConfigRegistry.loadForTest(simpleCfgs()); + JsonArray p = new JsonArray(); p.add("extra"); + assertEquals(-1, call("getCoins", p).getAsJsonObject("error").get("code").getAsInt()); + assertEquals(-1, call("getCoins", null).getAsJsonObject("error").get("code").getAsInt()); + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigSourceLocalTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigSourceLocalTest.java new file mode 100644 index 0000000..884ac05 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigSourceLocalTest.java @@ -0,0 +1,128 @@ +package io.xlite.daemon.app.coinconfig; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates CoinConfigSource logic with simple synthetic sets — no committed + * fixture, no ../ sibling. Production bcf is validated via gate and cross-check. + */ +class CoinConfigSourceLocalTest { + + private static CoinConfig ltcConfig() { + Map m = new LinkedHashMap<>(); + m.put("Title", "Litecoin"); + m.put("AddressPrefix", "48"); + m.put("ScriptPrefix", "50"); + m.put("SecretPrefix", "176"); + m.put("COIN", "100000000"); + m.put("FeePerByte", "10"); + m.put("MinTxFee", "5000"); + m.put("Port", "9332"); + m.put("DustAmount", "0"); + m.put("TxVersion", "2"); + return new CoinConfig("LTC", "Litecoin", "litecoin--v0.21.1", m); + } + + @Test + void testLitecoinValuesMatchShippedConf() { + CoinConfig ltc = ltcConfig(); + assertEquals("Litecoin", ltc.getBlockchain()); + assertEquals(48, ltc.addressPrefix()); + assertEquals(50, ltc.scriptPrefix()); + assertEquals(176, ltc.secretPrefix()); + assertEquals(100_000_000L, ltc.coinFactor()); + assertEquals(10L, ltc.feePerByte()); + assertEquals(5000L, ltc.minTxFee()); + assertEquals(9332, ltc.port()); + assertEquals(0L, ltc.dustAmountOrNull()); + } + + @Test + void testConfEntriesExposeFullRawMap() { + CoinConfig ltc = ltcConfig(); + assertEquals("Litecoin", ltc.getConfEntries().get("Title")); + assertTrue(ltc.getConfEntries().containsKey("TxVersion")); + } + + @Test + void testLoadsSyntheticManifest(@TempDir Path dir) throws Exception { + Files.createDirectories(dir.resolve("xbridge-confs")); + Files.writeString(dir.resolve("manifest-latest.json"), + "[{\"blockchain\":\"Litecoin\",\"ticker\":\"LTC\",\"xbridge_conf\":\"ltc.conf\"}," + + "{\"blockchain\":\"Blocknet\",\"ticker\":\"BLOCK\",\"xbridge_conf\":\"block.conf\"}]"); + Files.writeString(dir.resolve("xbridge-confs").resolve("ltc.conf"), + "[LTC]\nTitle=Litecoin\nAddressPrefix=48\nScriptPrefix=50\nSecretPrefix=176\nCOIN=100000000\nFeePerByte=10\nMinTxFee=5000\nPort=9332\nDustAmount=0\n"); + Files.writeString(dir.resolve("xbridge-confs").resolve("block.conf"), + "[BLOCK]\nTitle=Blocknet\nAddressPrefix=26\nScriptPrefix=28\nSecretPrefix=154\nCOIN=100000000\nFeePerByte=20\nMinTxFee=10000\nPort=41414\n"); + Map all = new CoinConfigSource(dir.toString()).loadAll(); + assertEquals(2, all.size()); + assertTrue(all.containsKey("LTC")); + assertTrue(all.containsKey("BLOCK")); + assertEquals(10L, all.get("LTC").feePerByte()); + } + + @Test + void testMissingSourceFailsHard() { + CoinConfigSource bad = new CoinConfigSource( + Paths.get("..", "no-such-bcf-dir").toAbsolutePath().normalize().toString()); + IllegalStateException e = assertThrows(IllegalStateException.class, bad::loadAll); + assertTrue(e.getMessage().contains("Cannot read")); + } + + @Test + void testManifestEntryWithoutSectionFailsHard(@TempDir Path dir) throws Exception { + Files.createDirectories(dir.resolve("xbridge-confs")); + Files.writeString(dir.resolve("manifest-latest.json"), + "[{\"blockchain\":\"Litecoin\",\"ticker\":\"LTC\"," + + "\"xbridge_conf\":\"ltc--v1.conf\"}]"); + Files.writeString(dir.resolve("xbridge-confs").resolve("ltc--v1.conf"), + "[OTHER]\nAddressPrefix=1\n"); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new CoinConfigSource(dir.toString()).loadAll()); + assertTrue(e.getMessage().contains("has no [LTC] section"), e.getMessage()); + } + + @Test + void testDuplicateManifestTickerKeepsLast(@TempDir Path dir) throws Exception { + Files.createDirectories(dir.resolve("xbridge-confs")); + Files.writeString(dir.resolve("manifest-latest.json"), + "[{\"blockchain\":\"A\",\"ticker\":\"LTC\",\"xbridge_conf\":\"a.conf\"}," + + "{\"blockchain\":\"B\",\"ticker\":\"LTC\",\"xbridge_conf\":\"b.conf\"}]"); + Files.writeString(dir.resolve("xbridge-confs").resolve("a.conf"), "[LTC]\nK=V1\n"); + Files.writeString(dir.resolve("xbridge-confs").resolve("b.conf"), "[LTC]\nK=V2\n"); + Map all = new CoinConfigSource(dir.toString()).loadAll(); + assertEquals(1, all.size()); + assertEquals("B", all.get("LTC").getBlockchain()); + assertEquals("V2", all.get("LTC").getConfEntries().get("K")); + } + + @Test + void testBadRootShapeFailsHardWithManifestContext(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("manifest-latest.json"), "{}"); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new CoinConfigSource(dir.toString()).loadAll()); + assertTrue(e.getMessage().startsWith("manifest:"), e.getMessage()); + assertTrue(e.getMessage().contains("contracts"), e.getMessage()); + } + + @Test + void testContractsWrappedManifestAlsoAccepted(@TempDir Path dir) throws Exception { + Files.createDirectories(dir.resolve("xbridge-confs")); + Files.writeString(dir.resolve("manifest-latest.json"), + "{\"contracts\":[{\"blockchain\":\"Dogecoin\",\"ticker\":\"DOGE\"," + + "\"xbridge_conf\":\"doge.conf\"}]}"); + Files.writeString(dir.resolve("xbridge-confs").resolve("doge.conf"), + "[DOGE]\nAddressPrefix=30\nFeePerByte=1\nMinTxFee=2\nCOIN=3\nPort=4\nScriptPrefix=22\nSecretPrefix=158\n"); + Map all = new CoinConfigSource(dir.toString()).loadAll(); + assertEquals(30, all.get("DOGE").addressPrefix()); + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigSourceRemoteTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigSourceRemoteTest.java new file mode 100644 index 0000000..f2907ee --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigSourceRemoteTest.java @@ -0,0 +1,327 @@ +package io.xlite.daemon.app.coinconfig; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Remote-path tests for {@link CoinConfigSource} via the stub fetch seam — + * no network. Covers the supported-ticker filter: only enum-listed tickers + * are ever fetched. + */ +class CoinConfigSourceRemoteTest { + + private static final String BASE = "https://example.invalid/bcf"; + + private static class StubFetch implements java.util.function.Function { + final Map bodies = new LinkedHashMap<>(); + final List calls = new ArrayList<>(); + + @Override + public String apply(String url) { + calls.add(url); + String body = bodies.get(url); + if (body == null) + throw new IllegalStateException("HTTP 404 fetching " + url); + return body; + } + } + + private static StubFetch fourTickerStub() { + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", + "[{\"blockchain\":\"Litecoin\",\"ticker\":\"LTC\",\"ver_id\":\"litecoin--v0.21.1\",\"xbridge_conf\":\"litecoin--v0.21.1.conf\"}," + + "{\"blockchain\":\"Dogecoin\",\"ticker\":\"DOGE\",\"ver_id\":\"doge--v1\",\"xbridge_conf\":\"doge--v1.conf\"}," + + "{\"blockchain\":\"Fake\",\"ticker\":\"FAKE\",\"ver_id\":\"fake--v1\",\"xbridge_conf\":\"fake--v1.conf\"}," + + "{\"blockchain\":\"BitcoinCash\",\"ticker\":\"BCH\",\"ver_id\":\"bch--v1\",\"xbridge_conf\":\"bch--v1.conf\"}]"); + stub.bodies.put(BASE + "/xbridge-confs/litecoin--v0.21.1.conf", + "[LTC]\nTitle=Litecoin\nAddressPrefix=48\nScriptPrefix=50\nSecretPrefix=176\nCOIN=100000000\nFeePerByte=10\nMinTxFee=5000\nPort=9332\n"); + stub.bodies.put(BASE + "/xbridge-confs/doge--v1.conf", + "[DOGE]\nTitle=Dogecoin\nAddressPrefix=30\nScriptPrefix=22\nSecretPrefix=158\nCOIN=100000000\nFeePerByte=10\nMinTxFee=1000\nPort=22555\n"); + return stub; + } + + @Test + void testOnlySupportedTickersFetched() { + StubFetch stub = fourTickerStub(); + CoinConfigSource src = new CoinConfigSource(BASE); + src.setRemoteFetch(stub); + Map all = src.loadAll(); + assertEquals(2, all.size()); + assertTrue(all.containsKey("LTC")); + assertTrue(all.containsKey("DOGE")); + assertEquals("litecoin--v0.21.1", all.get("LTC").getVerId()); + for (String url : stub.calls) { + assertFalse(url.contains("fake--v1.conf"), "FAKE must never be fetched: " + url); + assertFalse(url.contains("bch--v1.conf"), "BCH must never be fetched: " + url); + } + assertEquals(3, stub.calls.size(), "1 manifest + 2 supported confs, got " + stub.calls); + } + + @Test + void testAllUnsupportedManifestFailsHard() { + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", + "[{\"blockchain\":\"Fake\",\"ticker\":\"FAKE\",\"xbridge_conf\":\"fake--v1.conf\"}]"); + CoinConfigSource src = new CoinConfigSource(BASE); + src.setRemoteFetch(stub); + IllegalStateException e = assertThrows(IllegalStateException.class, src::loadAll); + assertTrue(e.getMessage().contains("no loadable entries")); + assertEquals(1, stub.calls.size(), "only the manifest is fetched, got " + stub.calls); + } + + @Test + void testManifestFetchFailurePropagates() { + StubFetch stub = new StubFetch(); + CoinConfigSource src = new CoinConfigSource(BASE); + src.setRemoteFetch(stub); + assertThrows(IllegalStateException.class, src::loadAll); + } + + // --- cache / compare / parallel --- + + private static final String LTC_CONF_V1 = + "[LTC]\nTitle=Litecoin\nAddressPrefix=48\nScriptPrefix=50\nSecretPrefix=176\nCOIN=100000000\nFeePerByte=10\nMinTxFee=5000\nPort=9332\n"; + private static final String DOGE_CONF = + "[DOGE]\nTitle=Dogecoin\nAddressPrefix=30\nScriptPrefix=22\nSecretPrefix=158\nCOIN=100000000\nFeePerByte=10\nMinTxFee=1000\nPort=22555\n"; + + private static String manifestOf(String... entries) { + return "[" + String.join(",", entries) + "]"; + } + + private static String manifestEntry(String blockchain, String ticker, String verId, String conf) { + return "{\"blockchain\":\"" + blockchain + "\",\"ticker\":\"" + ticker + + "\",\"ver_id\":\"" + verId + "\",\"xbridge_conf\":\"" + conf + "\"}"; + } + + private CoinConfigSource cachedSource(Path root, String manifestJson, Map confs, + StubFetch stub) { + CoinConfigCache cache = CoinConfigCache.forSource(root, BASE); + cache.storeManifest(manifestJson); + for (Map.Entry e : confs.entrySet()) + cache.storeConf(e.getKey(), e.getValue()); + CoinConfigSource src = new CoinConfigSource(BASE); + src.setRemoteFetch(stub); + src.setCache(cache); + return src; + } + + @Test + void testFullCacheHitFetchesNoConfs(@TempDir Path root) { + String manifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", "litecoin--v1.conf"), + manifestEntry("Dogecoin", "DOGE", "doge--v1", "doge--v1.conf")); + Map confs = new LinkedHashMap<>(); + confs.put("litecoin--v1.conf", LTC_CONF_V1); + confs.put("doge--v1.conf", DOGE_CONF); + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", manifest); + stub.bodies.put(BASE + "/xbridge-confs/litecoin--v1.conf", LTC_CONF_V1); + stub.bodies.put(BASE + "/xbridge-confs/doge--v1.conf", DOGE_CONF); + Map all = cachedSource(root, manifest, confs, stub).loadAll(); + assertEquals(2, all.size()); + assertEquals(10L, all.get("LTC").feePerByte()); + assertEquals(1000L, all.get("DOGE").minTxFee()); + assertEquals(1, stub.calls.size(), "only the manifest is fetched, got " + stub.calls); + assertTrue(stub.calls.get(0).endsWith("manifest-latest.json")); + } + + @Test + void testSingleVerIdBumpFetchesExactlyOneConf(@TempDir Path root) { + String oldManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--vOLD", "litecoin--vOLD.conf"), + manifestEntry("Dogecoin", "DOGE", "doge--v1", "doge--v1.conf")); + Map oldConfs = new LinkedHashMap<>(); + oldConfs.put("litecoin--vOLD.conf", LTC_CONF_V1); + oldConfs.put("doge--v1.conf", DOGE_CONF); + String newManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--vNEW", "litecoin--vNEW.conf"), + manifestEntry("Dogecoin", "DOGE", "doge--v1", "doge--v1.conf")); + String newLtc = LTC_CONF_V1.replace("FeePerByte=10", "FeePerByte=11"); + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", newManifest); + stub.bodies.put(BASE + "/xbridge-confs/litecoin--vNEW.conf", newLtc); + Map all = cachedSource(root, oldManifest, oldConfs, stub).loadAll(); + assertEquals(11L, all.get("LTC").feePerByte(), "fresh conf wins"); + assertEquals(1000L, all.get("DOGE").minTxFee(), "unchanged coin served from cache"); + assertEquals(2, stub.calls.size(), "manifest + 1 delta, got " + stub.calls); + } + + @Test + void testRemovedTickerDroppedAndNeverFetched(@TempDir Path root) { + String oldManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", "litecoin--v1.conf"), + manifestEntry("Dogecoin", "DOGE", "doge--v1", "doge--v1.conf")); + Map oldConfs = new LinkedHashMap<>(); + oldConfs.put("litecoin--v1.conf", LTC_CONF_V1); + oldConfs.put("doge--v1.conf", "[DOGE]\nTitle=Dogecoin\n"); + String newManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", "litecoin--v1.conf")); + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", newManifest); + Map all = cachedSource(root, oldManifest, oldConfs, stub).loadAll(); + assertEquals(1, all.size()); + assertTrue(all.containsKey("LTC")); + for (String url : stub.calls) + assertFalse(url.contains("doge--v1.conf"), "removed ticker must never be fetched"); + } + + @Test + void testCorruptCachedConfRefetched(@TempDir Path root) { + String manifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", "litecoin--v1.conf")); + Map corrupt = new LinkedHashMap<>(); + corrupt.put("litecoin--v1.conf", "garbage ((( no sections here"); + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", manifest); + stub.bodies.put(BASE + "/xbridge-confs/litecoin--v1.conf", LTC_CONF_V1); + Map all = cachedSource(root, manifest, corrupt, stub).loadAll(); + assertEquals(10L, all.get("LTC").feePerByte(), "refetched conf wins over corrupt cache"); + assertEquals(2, stub.calls.size(), "manifest + refetch, got " + stub.calls); + } + + @Test + void testParallelMatchesSerialDeterministically() { + String[] tickers = {"BLOCK", "DOGE", "BTC", "LTC", "DASH", "DOGE", "SYS", "PIVX", "DGB", "RVN", "UNO", "PKOIN"}; + List entries = new ArrayList<>(); + final Map confBodies = new LinkedHashMap<>(); + for (String t : tickers) { + entries.add(manifestEntry("Chain" + t, t, "v1", t.toLowerCase() + ".conf")); + confBodies.put(BASE + "/xbridge-confs/" + t.toLowerCase() + ".conf", "[" + t + "]\nTitle=" + t + "\n"); + } + String manifest = manifestOf(entries.toArray(new String[0])); + Function jittered = url -> { + if (url.endsWith("manifest-latest.json")) + return manifest; + try { + Thread.sleep(ThreadLocalRandom.current().nextInt(15)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + String body = confBodies.get(url); + if (body == null) + throw new IllegalStateException("HTTP 404 fetching " + url); + return body; + }; + CoinConfigSource first = new CoinConfigSource(BASE); + first.setRemoteFetch(jittered); + CoinConfigSource second = new CoinConfigSource(BASE); + second.setRemoteFetch(jittered); + Map a = first.loadAll(); + Map b = second.loadAll(); + assertEquals(11, a.size()); + assertEquals(a, b, "parallel fetch must be deterministic across runs"); + assertEquals(new ArrayList<>(a.keySet()), new ArrayList<>(b.keySet()), "manifest order preserved"); + } + + @Test + void testFetchCapFallsBackToCache(@TempDir Path root) { + String manifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", "litecoin--v1.conf"), + manifestEntry("Bitcoin", "BTC", "bitcoin--v1", "bitcoin--v1.conf")); + Map partial = new LinkedHashMap<>(); + partial.put("litecoin--v1.conf", LTC_CONF_V1); + CoinConfigCache cache = CoinConfigCache.forSource(root, BASE); + cache.storeManifest(manifest); + for (Map.Entry e : partial.entrySet()) + cache.storeConf(e.getKey(), e.getValue()); + final List calls = new ArrayList<>(); + Function hanging = url -> { + calls.add(url); + if (url.endsWith("manifest-latest.json")) + return manifest; + try { + new CountDownLatch(1).await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IllegalStateException("fetch should have been capped"); + }; + CoinConfigSource src = new CoinConfigSource(BASE); + src.setRemoteFetch(hanging); + src.setCache(cache); + src.setFetchPhaseTimeoutForTest(1); + long start = System.currentTimeMillis(); + Map all = src.loadAll(); + long elapsed = System.currentTimeMillis() - start; + assertTrue(all.containsKey("LTC"), "cached coin served despite cap"); + assertFalse(all.containsKey("BTC"), "uncached coin skipped past the cap"); + assertTrue(calls.stream().anyMatch(u -> u.endsWith("bitcoin--v1.conf")), "fetch was attempted"); + assertTrue(elapsed < 30_000, "cap bounds the phase, took " + elapsed + "ms"); + } + + @Test + void testSameFilenameContentChangeRefetched(@TempDir Path root) { + String file = "litecoin.conf"; + String oldManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", file)); + Map oldConfs = new LinkedHashMap<>(); + oldConfs.put(file, LTC_CONF_V1); + String newManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v2", file)); + String newConf = LTC_CONF_V1.replace("FeePerByte=10", "FeePerByte=12"); + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", newManifest); + stub.bodies.put(BASE + "/xbridge-confs/" + file, newConf); + Map all = cachedSource(root, oldManifest, oldConfs, stub).loadAll(); + assertEquals(12L, all.get("LTC").feePerByte(), "same-filename change must refetch, not serve stale"); + assertEquals(2, stub.calls.size(), "manifest + refetch, got " + stub.calls); + } + + @Test + void testFailedLoadPreservesLastGoodCache(@TempDir Path root) { + String oldManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", "litecoin--v1.conf")); + Map oldConfs = new LinkedHashMap<>(); + oldConfs.put("litecoin--v1.conf", LTC_CONF_V1); + String newManifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v2", "litecoin--v2.conf")); + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", newManifest); + CoinConfigCache cache = CoinConfigCache.forSource(root, BASE); + cache.storeManifest(oldManifest); + for (Map.Entry e : oldConfs.entrySet()) + cache.storeConf(e.getKey(), e.getValue()); + CoinConfigSource src = new CoinConfigSource(BASE); + src.setRemoteFetch(stub); + src.setCache(cache); + IllegalStateException e = assertThrows(IllegalStateException.class, src::loadAll); + assertTrue(e.getMessage().contains("no loadable entries"), e.getMessage()); + assertEquals(oldManifest, cache.cachedManifest().orElseThrow(AssertionError::new), + "failed load must leave the last-good manifest for fallback"); + assertEquals(LTC_CONF_V1, cache.cachedConf("litecoin--v1.conf").orElseThrow(AssertionError::new)); + } + + @Test + void testSourceSwitchRefetchesFully(@TempDir Path root) { + String base2 = "https://example.invalid/bcf2"; + String manifest = manifestOf( + manifestEntry("Litecoin", "LTC", "litecoin--v1", "litecoin--v1.conf")); + StubFetch stub = new StubFetch(); + stub.bodies.put(BASE + "/manifest-latest.json", manifest); + stub.bodies.put(BASE + "/xbridge-confs/litecoin--v1.conf", LTC_CONF_V1); + stub.bodies.put(base2 + "/manifest-latest.json", manifest); + stub.bodies.put(base2 + "/xbridge-confs/litecoin--v1.conf", LTC_CONF_V1); + CoinConfigSource first = new CoinConfigSource(BASE); + first.setRemoteFetch(stub); + first.setCache(CoinConfigCache.forSource(root, BASE)); + assertEquals(1, first.loadAll().size()); + int callsAfterFirst = stub.calls.size(); + CoinConfigSource second = new CoinConfigSource(base2); + second.setRemoteFetch(stub); + second.setCache(CoinConfigCache.forSource(root, base2)); + assertEquals(1, second.loadAll().size()); + assertEquals(callsAfterFirst + 2, stub.calls.size(), + "switched source refetches manifest + conf, got " + stub.calls); + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigTest.java new file mode 100644 index 0000000..e784f51 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/CoinConfigTest.java @@ -0,0 +1,81 @@ +package io.xlite.daemon.app.coinconfig; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CoinConfigTest { + + private CoinConfig sample() { + Map entries = new LinkedHashMap<>(); + entries.put("AddressPrefix", "48"); + entries.put("ScriptPrefix", "50"); + entries.put("SecretPrefix", "176"); + entries.put("COIN", "100000000"); + entries.put("FeePerByte", "10"); + entries.put("MinTxFee", "5000"); + entries.put("Port", "9332"); + return new CoinConfig("LTC", "Litecoin", "litecoin--v0.21.1", entries); + } + + @Test + void testTypedAccessorsParseShippedValues() { + CoinConfig c = sample(); + assertEquals(48, c.addressPrefix()); + assertEquals(50, c.scriptPrefix()); + assertEquals(176, c.secretPrefix()); + assertEquals(100_000_000L, c.coinFactor()); + assertEquals(10L, c.feePerByte()); + assertEquals(5000L, c.minTxFee()); + assertEquals(9332, c.port()); + } + + @Test + void testMissingKeyFailsHardWithTickerContext() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new CoinConfig("X", "Xcoin", "", Map.of("A", "1")).feePerByte()); + assertTrue(e.getMessage().contains("[X] missing required config key 'FeePerByte'"), e.getMessage()); + } + + @Test + void testMalformedNumberFailsHardWithContext() { + Map bad = new LinkedHashMap<>(); + bad.put("FeePerByte", "abc"); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new CoinConfig("X", "Xcoin", "", bad).feePerByte()); + assertTrue(e.getMessage().contains("[X] config key 'FeePerByte' is not a number: 'abc'"), e.getMessage()); + } + + @Test + void testDustAbsentNullMalformedThrows() { + assertNull(sample().dustAmountOrNull()); + + Map withDust = new LinkedHashMap<>(); + withDust.put("DustAmount", "5460"); + assertEquals(5460L, new CoinConfig("X", "X", "", withDust).dustAmountOrNull()); + + Map badDust = new LinkedHashMap<>(); + badDust.put("DustAmount", "zero"); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new CoinConfig("X", "X", "", badDust).dustAmountOrNull()); + assertTrue(e.getMessage().contains("'DustAmount' is not a number"), e.getMessage()); + } + + @Test + void testPortOverflowFailsHardWithContext() { + Map bad = new LinkedHashMap<>(); + bad.put("Port", "99999999999"); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new CoinConfig("X", "X", "", bad).port()); + assertTrue(e.getMessage().contains("[X] config key 'Port' exceeds the int range"), e.getMessage()); + } + + @Test + void testConfEntriesUnmodifiable() { + assertThrows(UnsupportedOperationException.class, + () -> sample().getConfEntries().put("New", "1")); + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/ConfigSourceResolverTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/ConfigSourceResolverTest.java new file mode 100644 index 0000000..d95b9f9 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/ConfigSourceResolverTest.java @@ -0,0 +1,90 @@ +package io.xlite.daemon.app.coinconfig; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ConfigSourceResolverTest { + + private static final String UPSTREAM = + "https://raw.githubusercontent.com/blocknetdx/blockchain-configuration-files/master"; + + @Test + void testFlagWinsOverEnvOverDefault() { + assertEquals("/opt/bcf", + new ConfigSourceResolver("/opt/bcf", "/env/bcf").resolve()); + assertEquals("/env/bcf", + new ConfigSourceResolver(null, "/env/bcf").resolve()); + assertEquals(UPSTREAM, + new ConfigSourceResolver(null, null).resolve()); + assertEquals(UPSTREAM, + new ConfigSourceResolver(" ", "").resolve()); + } + + @Test + void testBlankValuesFallThrough() { + assertEquals("/env/bcf", new ConfigSourceResolver("", " /env/bcf ").resolve()); + } + + @Test + void testTrailingSlashesTrimmed() { + assertEquals("/opt/bcf", new ConfigSourceResolver("/opt/bcf///", null).resolve()); + assertEquals(UPSTREAM, new ConfigSourceResolver(UPSTREAM + "/", null).resolve()); + } + + @Test + void testGithubRepoUrlNormalizedToRawMaster() { + assertEquals(UPSTREAM, + new ConfigSourceResolver("https://github.com/blocknetdx/blockchain-configuration-files", null).resolve()); + } + + @Test + void testGithubTreeUrlNormalizedToRawBranch() { + assertEquals("https://raw.githubusercontent.com/blocknetdx/blockchain-configuration-files/develop", + new ConfigSourceResolver("https://github.com/blocknetdx/blockchain-configuration-files/tree/develop", null).resolve()); + } + + @Test + void testGithubDotGitSuffixStripped() { + assertEquals(UPSTREAM, + new ConfigSourceResolver("https://github.com/blocknetdx/blockchain-configuration-files.git", null).resolve()); + } + + @Test + void testGithubBlobUrlNormalized() { + assertEquals("https://raw.githubusercontent.com/blocknetdx/blockchain-configuration-files/master", + new ConfigSourceResolver("https://github.com/blocknetdx/blockchain-configuration-files/blob/master", null).resolve()); + } + + @Test + void testUppercaseSchemeNormalized() { + assertEquals(UPSTREAM, + new ConfigSourceResolver("HTTPS://GitHub.com/blocknetdx/blockchain-configuration-files", null).resolve()); + } + + @Test + void testDeepGithubPathFailsHard() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> new ConfigSourceResolver( + "https://github.com/blocknetdx/blockchain-configuration-files/tree/develop/src", null) + .resolve()); + assertTrue(e.getMessage().contains("unsupported github URL form"), e.getMessage()); + } + + @Test + void testUppercaseSchemeIsRemoteNotLocalDir() { + assertFalse(ConfigSourceResolver.isLocalDirectory("HTTP://127.0.0.1:8080/bcf")); + } + + @Test + void testPlainHttpKept() { + assertEquals("http://127.0.0.1:8080/bcf", + new ConfigSourceResolver("http://127.0.0.1:8080/bcf", null).resolve()); + } + + @Test + void testLocalDirectoryDetectedAsNonUrl() { + assertTrue(ConfigSourceResolver.isLocalDirectory("/opt/bcf")); + assertFalse(ConfigSourceResolver.isLocalDirectory(UPSTREAM)); + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/ConfigurableNetworkParametersCrossCheckTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/ConfigurableNetworkParametersCrossCheckTest.java new file mode 100644 index 0000000..956b304 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/ConfigurableNetworkParametersCrossCheckTest.java @@ -0,0 +1,203 @@ +package io.xlite.daemon.app.coinconfig; + +import io.xlite.daemon.app.net.protocols.bitcoin.BitcoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.bitcoincash.BitcoinCashNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.dashcoin.DashcoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.digibyte.DigibyteNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.dogecoin.DogecoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.litecoin.LitecoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.pivx.PivxNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.pocketcoin.PocketcoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.ravencoin.RavencoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.syscoin.SyscoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.unobtanium.UnobtaniumNetworkParametersLegacy; +import io.xlite.daemon.app.wallet.WalletHelper; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.junit.jupiter.api.Test; + +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Migration safety net: for every migrated coin, the bcf-driven parameter + * set must reproduce the legacy hardcoded classes exactly — EXCEPT the two + * approved corrections, pinned explicitly below: + * - DGB ScriptPrefix 5 -> 63 (bcf carries the current-era value) + * - RVN FeePerByte 1000 -> 3000 (bcf carries the product's creation rate) + * + *

Getters NOT compared here — each verified to be a dead code path in this + * daemon (zero callers on migrated coins):

+ *
    + *
  • {@code getUriScheme()} — legacy used real scheme names ("litecoin:"), + * the generic class derives "ticker:"
  • + *
  • {@code getInterval()}/{@code getSubsidyDecreaseBlockCount()} — inert + * stubs vs per-coin legacy values (no BlockChain exists here)
  • + *
  • {@code getProtocolVersionNum(non-CURRENT)} — BCH legacy was + * version-dependent, the generic class returns its fixed value
  • + *
  • {@code getPort()} — see the disclosure on + * {@link ConfigurableNetworkParameters}
  • + *
+ */ +class ConfigurableNetworkParametersCrossCheckTest { + + private static final byte[] HASH160 = new byte[20]; + + static { + for (int i = 0; i < 20; i++) HASH160[i] = (byte) (0xA0 + i); + } + + private Map configs() { + // Simple in-memory set — validates logic without filesystem fixture + Map m = new LinkedHashMap<>(); + // LTC + Map ltc = new LinkedHashMap<>(); + ltc.put("AddressPrefix", "48"); ltc.put("ScriptPrefix", "50"); ltc.put("SecretPrefix", "176"); + ltc.put("COIN", "100000000"); ltc.put("FeePerByte", "10"); ltc.put("MinTxFee", "5000"); + ltc.put("Port", "9332"); ltc.put("DustAmount", "0"); + m.put("LTC", new CoinConfig("LTC", "Litecoin", "litecoin--v0.21.1", ltc)); + // BLOCK + Map block = new LinkedHashMap<>(); + block.put("AddressPrefix", "26"); block.put("ScriptPrefix", "28"); block.put("SecretPrefix", "154"); + block.put("COIN", "100000000"); block.put("FeePerByte", "20"); block.put("MinTxFee", "10000"); + block.put("Port", "41414"); block.put("DustAmount", "0"); + m.put("BLOCK", new CoinConfig("BLOCK", "Blocknet", "blocknet--v4.2.0", block)); + // DGB with bcf-correct 63 (legacy is 5) + Map dgb = new LinkedHashMap<>(); + dgb.put("AddressPrefix", "30"); dgb.put("ScriptPrefix", "63"); dgb.put("SecretPrefix", "128"); + dgb.put("COIN", "100000000"); dgb.put("FeePerByte", "200"); dgb.put("MinTxFee", "100000"); + dgb.put("Port", "14022"); dgb.put("DustAmount", "0"); + m.put("DGB", new CoinConfig("DGB", "DigiByte", "digibyte--v9.26.5", dgb)); + // RVN with bcf-correct 3000 (legacy 1000) + Map rvn = new LinkedHashMap<>(); + rvn.put("AddressPrefix", "60"); rvn.put("ScriptPrefix", "122"); rvn.put("SecretPrefix", "128"); + rvn.put("COIN", "100000000"); rvn.put("FeePerByte", "3000"); rvn.put("MinTxFee", "100000"); + rvn.put("Port", "8766"); rvn.put("DustAmount", "0"); + m.put("RVN", new CoinConfig("RVN", "Ravencoin", "raven--v4.8.0", rvn)); + return m; + } + + @Test + void testGenericReproducesEveryLiveGetterOfLegacyClasses() { + Map cfgs = configs(); + + forLegacy(new String[]{"LTC"}, cfgs, false, false); + // DGB: only the P2SH header intentionally differs (5 -> 63) + forLegacy(new String[]{"DGB"}, cfgs, true, false); + // RVN: only the fee intentionally differs (1000 -> 3000) + forLegacy(new String[]{"RVN"}, cfgs, false, true); + } + + private void forLegacy(String[] tickers, Map cfgs, + boolean dgbP2shDiff, boolean rvnFeeDiff) { + for (String ticker : tickers) { + NetworkParametersPair pair = pairFor(ticker, cfgs); + + assertEquals(pair.legacy.getAddressHeader(), + pair.generic.getAddressHeader(), ticker + " addressHeader"); + if (!dgbP2shDiff) { + assertEquals(pair.legacy.getP2SHHeader(), + pair.generic.getP2SHHeader(), ticker + " p2shHeader"); + } else { + assertEquals(5, pair.legacy.getP2SHHeader()); + assertEquals(63, pair.generic.getP2SHHeader(), "DGB must adopt bcf 63"); + } + assertEquals(pair.legacy.getDumpedPrivateKeyHeader(), + pair.generic.getDumpedPrivateKeyHeader(), ticker + " dumpedPrivateKeyHeader"); + assertEquals(pair.legacy.getMaxMoney().getValue(), + pair.generic.getMaxMoney().getValue(), ticker + " maxMoney"); + assertEquals(pair.legacy.getMinNonDustOutput().getValue(), + pair.generic.getMinNonDustOutput().getValue(), ticker + " minNonDustOutput"); + assertEquals(pair.legacy.getProtocolVersionNum(NetworkParameters.ProtocolVersion.CURRENT), + pair.generic.getProtocolVersionNum(NetworkParameters.ProtocolVersion.CURRENT), + ticker + " protocolVersion"); + assertEquals(pair.legacy.getId(), pair.generic.getId(), ticker + " id"); + assertNotEquals(NetworkParameters.ID_UNITTESTNET, pair.generic.getId()); + assertEquals(pair.legacy.getPaymentProtocolId(), + pair.generic.getPaymentProtocolId(), ticker + " paymentProtocolId"); + + long legacyFee = WalletHelper.getFeePerByte(pair.legacy); + long genericFee = WalletHelper.getFeePerByte(pair.generic); + if (!rvnFeeDiff) { + assertEquals(legacyFee, genericFee, ticker + " feePerByte"); + } else { + assertEquals(1000L, legacyFee); + assertEquals(3000L, genericFee, "RVN must adopt bcf 3000 sat/B"); + } + assertEquals(WalletHelper.getMinTxFee(pair.legacy), + WalletHelper.getMinTxFee(pair.generic), + ticker + " minTxFee"); + + // end-to-end verbyte equivalence: same legacy address string + LegacyAddress fromLegacy = LegacyAddress.fromPubKeyHash(pair.legacy, HASH160); + LegacyAddress fromGeneric = LegacyAddress.fromPubKeyHash(pair.generic, HASH160); + assertEquals(fromLegacy.toString(), fromGeneric.toString(), ticker + " address encoding"); + + assertNotNull(pair.generic.getSerializer(false), ticker + " serializer"); + } + } + + @Test + void testGateRejectsCorruptedConfig() { + CoinConfig good = configs().get("LTC"); + assertNotNull(good, "LTC missing from manifest - cross-check baseline broken"); + Map broken = new LinkedHashMap<>(good.getConfEntries()); + broken.put("AddressPrefix", "9999"); + CoinConfig bad = new CoinConfig(good.getTicker(), good.getBlockchain(), + good.getVerId(), broken); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> ConfigurableNetworkParameters.from(bad)); + assertTrue(e.getMessage().contains("coin config rejected"), e.getMessage()); + assertTrue(e.getMessage().contains("out of byte range"), e.getMessage()); + } + + private interface LegacyFactory { + NetworkParameters create(); + } + + private NetworkParametersPair pairFor(String ticker, Map cfgs) { + LegacyFactory factory; + switch (ticker) { + case "BTC": + factory = BitcoinNetworkParametersLegacy::new; break; + case "BCH": + factory = BitcoinCashNetworkParametersLegacy::new; break; + case "DASH": + factory = DashcoinNetworkParametersLegacy::new; break; + case "DGB": + factory = DigibyteNetworkParametersLegacy::new; break; + case "DOGE": + factory = DogecoinNetworkParametersLegacy::new; break; + case "LTC": + factory = LitecoinNetworkParametersLegacy::new; break; + case "PIVX": + factory = PivxNetworkParametersLegacy::new; break; + case "PKOIN": + factory = PocketcoinNetworkParametersLegacy::new; break; + case "RVN": + factory = RavencoinNetworkParametersLegacy::new; break; + case "SYS": + factory = SyscoinNetworkParametersLegacy::new; break; + case "UNO": + factory = UnobtaniumNetworkParametersLegacy::new; break; + default: + throw new IllegalArgumentException(ticker); + } + return new NetworkParametersPair(factory.create(), + ConfigurableNetworkParameters.from(cfgs.get(ticker))); + } + + private static final class NetworkParametersPair { + final NetworkParameters legacy; + final NetworkParameters generic; + + NetworkParametersPair(NetworkParameters legacy, + NetworkParameters generic) { + this.legacy = legacy; + this.generic = generic; + } + } +} diff --git a/src/test/java/io/xlite/daemon/app/coinconfig/XBridgeConfParserTest.java b/src/test/java/io/xlite/daemon/app/coinconfig/XBridgeConfParserTest.java new file mode 100644 index 0000000..ef4d84d --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/coinconfig/XBridgeConfParserTest.java @@ -0,0 +1,98 @@ +package io.xlite.daemon.app.coinconfig; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class XBridgeConfParserTest { + + private static final String SAMPLE = + "# top comment\n" + + "[LTC]\n" + + "Title=Litecoin\n" + + " AddressPrefix = 48 \n" + + "FeePerByte=10\n" + + "; ini-style comment\n" + + "\n" + + "[BLOCK]\n" + + "Title=Blocknet\n" + + "AddressPrefix=26\n"; + + @Test + void testSectionsAndKeysParsed() { + Map> all = XBridgeConfParser.parse(SAMPLE); + assertEquals(2, all.size()); + Map ltc = all.get("LTC"); + assertEquals("48", ltc.get("AddressPrefix")); + assertEquals("10", ltc.get("FeePerByte")); + assertEquals("Litecoin", ltc.get("Title")); + assertEquals("26", all.get("BLOCK").get("AddressPrefix")); + } + + @Test + void testValuesTrimmedCommentsSkipped() { + Map> all = XBridgeConfParser.parse(SAMPLE); + assertFalse(all.get("LTC").containsKey("# top comment")); + assertFalse(all.get("LTC").containsKey("; ini-style comment")); + assertEquals("48", all.get("LTC").get("AddressPrefix")); // inner spaces trimmed + } + + @Test + void testMalformedLineOutsideSectionThrows() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> XBridgeConfParser.parse("Orphan=1\n[A]\nK=V\n")); + assertTrue(e.getMessage().contains("outside any section")); + } + + @Test + void testMalformedLineInsideSectionThrows() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> XBridgeConfParser.parse("[A]\nJustAK\nK=V\n")); + assertTrue(e.getMessage().contains("in [A]")); + } + + @Test + void testNestedBracketHeaderThrows() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> XBridgeConfParser.parse("[A][B]\nK=V\n")); + assertTrue(e.getMessage().contains("Malformed section header"), e.getMessage()); + } + + @Test + void testDuplicateKeysLastWins() { + Map> all = XBridgeConfParser.parse("[A]\nK=1\nK=2\n"); + assertEquals("2", all.get("A").get("K")); + } + + @Test + void testDuplicateSectionsMerge() { + Map> all = XBridgeConfParser.parse("[A]\nK=1\n[A]\nJ=2\n"); + assertEquals(1, all.size()); + assertEquals("1", all.get("A").get("K")); + assertEquals("2", all.get("A").get("J")); + } + + @Test + void testUtf8BomStrippedBeforeFirstSection() { + Map> all = XBridgeConfParser.parse("\uFEFF[LTC]\nAddressPrefix=48\n"); + assertEquals("48", all.get("LTC").get("AddressPrefix")); + } + + @Test + void testEmptySectionHeaderThrows() { + assertThrows(IllegalStateException.class, () -> XBridgeConfParser.parse("[]\nK=V\n")); + } + + @Test + void testCrlfHandled() { + Map> all = XBridgeConfParser.parse("[X]\r\nA=1\r\n"); + assertEquals("1", all.get("X").get("A")); + } + + @Test + void testEmptyInputYieldsEmptyMap() { + assertTrue(XBridgeConfParser.parse("").isEmpty()); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/CoinTickerSourceOfTruthTest.java b/src/test/java/io/xlite/daemon/app/net/CoinTickerSourceOfTruthTest.java new file mode 100644 index 0000000..d43c7b5 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/CoinTickerSourceOfTruthTest.java @@ -0,0 +1,60 @@ +package io.xlite.daemon.app.net; + +import io.xlite.daemon.app.coinconfig.CompiledCoinSupplement; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The {@link CoinTicker} enum is the single source of truth for supported + * coins. Everything else derives: active set, string mappings, compiled + * chain supplements. Adding/delisting a coin is one enum edit; any + * hand-maintained parallel list drifting from it fails here. + */ +public class CoinTickerSourceOfTruthTest { + + @Test + void testActiveTickersMatchEnum() { + // The full explicit active set: a dropped/added middle coin changes + // this set and fails here (a purely derived expectation would shrink + // together with production and pass silently). + Set expected = EnumSet.of(CoinTicker.BLOCKNET, CoinTicker.BITCOIN, CoinTicker.LITECOIN, + CoinTicker.DASHCOIN, CoinTicker.DOGECOIN, CoinTicker.SYSCOIN, CoinTicker.PIVX, + CoinTicker.DIGIBYTE, CoinTicker.RAVENCOIN, CoinTicker.UNOBTANIUM, CoinTicker.PKOIN); + Set active = new HashSet<>(Arrays.asList(CoinTickerUtils.getActiveTickers())); + assertEquals(expected, active); + // Spare rows for inactive coins (e.g. the BCH supplement entry, kept + // for future activation) are intentional and NOT flagged here; only + // the active set is pinned. + for (CoinTicker t : CoinTickerUtils.getActiveTickers()) { + assertTrue(CoinTickerUtils.isActiveTicker(t)); + } + } + + @Test + void testEveryActiveTickerResolvesBothWays() { + for (CoinTicker t : CoinTickerUtils.getActiveTickers()) { + String s = CoinTickerUtils.tickerToString(t); + assertNotNull(s, t.name()); + assertEquals(t, CoinTickerUtils.stringToTicker(s)); + } + } + + @Test + void testEveryActiveCoinHasCompiledSupplement() { + // BLOCKNET uses hardcoded network parameters (CoinInstance init); + // all others must have a supplement row or wallet init fails with + // UNSUPPORTEDCOIN at runtime. + for (CoinTicker t : CoinTickerUtils.getActiveTickers()) { + if (t == CoinTicker.BLOCKNET) { + continue; + } + assertDoesNotThrow(() -> CompiledCoinSupplement.forTicker(CoinTickerUtils.tickerToString(t)), t.name()); + } + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/JSONRPCControllerRebindTest.java b/src/test/java/io/xlite/daemon/app/net/api/JSONRPCControllerRebindTest.java new file mode 100644 index 0000000..b17a152 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/JSONRPCControllerRebindTest.java @@ -0,0 +1,120 @@ +package io.xlite.daemon.app.net.api; + +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.util.ConfigHelper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.ServerSocket; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Pins the controller's rebind handoff contract: a rebind retires the + * previous instance and installs a fresh one atomically from the caller's + * point of view, including when the retiring server is LIVE (real socket + * teardown path). + * + * Coverage honesty: the concurrent soak exercises interleaved rebinds on + * unstarted servers and proves robustness/coherence, but cannot make the + * kernel-release lag window materialize on demand — full interleaved + * reproduction of the retirement race is covered by the stack wave + * detectors (per-coin post-rebind port probe, lifecycle-anomaly logsweep), + * which have proven this path across all coins. + */ +public class JSONRPCControllerRebindTest { + + @TempDir + static Path tempConfigDir; + + private static int freePort() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + @BeforeAll + static void isolateConfig() { + ConfigHelper.CONFIG_DIR = tempConfigDir.toString(); + } + + @Test + public void testRebind_ReturnsFreshInstanceAndSwapsHandout() throws Exception { + CoinInstance coin = mock(CoinInstance.class); + int port = freePort(); + when(coin.getRPCPort()).thenReturn(port); + + JSONRPCServer first = JSONRPCController.getRPCServer(coin); + JSONRPCServer rebound = JSONRPCController.rebindRPCServer(coin); + + assertNotNull(rebound); + assertNotSame(first, rebound); + assertSame(rebound, JSONRPCController.getRPCServer(coin)); + } + + @Test + public void testRebind_RetiresLiveServerAndInstallsFresh() throws Exception { + CoinInstance coin = mock(CoinInstance.class); + int port = freePort(); + when(coin.getRPCPort()).thenReturn(port); + + JSONRPCServer live = JSONRPCController.getRPCServer(coin); + live.start(); + assertTrue(live.awaitBound(5000), "precondition: live server bound"); + + JSONRPCServer rebound = JSONRPCController.rebindRPCServer(coin); + + assertNotSame(live, rebound); + // Controller hands out unstarted servers — starting is the + // caller's job (reloadConfig does exactly this). + rebound.start(); + // The old instance must be retired and its thread fully unwound + // (bounded join: isAlive() can lag closeFuture completion by the + // run() method's exit path) and the replacement must bind. + live.join(5000); + assertFalse(live.isAlive()); + assertTrue(rebound.awaitBound(10000), "replacement server must bind"); + assertSame(rebound, JSONRPCController.getRPCServer(coin)); + } + + @Test + public void testRebind_ConcurrentCycles_NoDeadlock_CoherentEndState() throws Exception { + CoinInstance coin = mock(CoinInstance.class); + int port = freePort(); + when(coin.getRPCPort()).thenReturn(port); + + final int threads = 8; + final int cyclesPerThread = 25; + List handedOut = + Collections.synchronizedList(new ArrayList<>()); + + ExecutorService pool = Executors.newFixedThreadPool(threads); + List> tasks = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + tasks.add(() -> { + for (int i = 0; i < cyclesPerThread; i++) { + handedOut.add(JSONRPCController.rebindRPCServer(coin)); + } + return null; + }); + } + + List> futures = pool.invokeAll(tasks); + for (Future f : futures) { + f.get(30, TimeUnit.SECONDS); + } + + // Every consumer received an instance, none was null, and the + // final handout equals the map's current server. + assertEquals(threads * cyclesPerThread, handedOut.size()); + assertTrue(handedOut.contains(JSONRPCController.getRPCServer(coin))); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/JSONRPCServerPortGateTest.java b/src/test/java/io/xlite/daemon/app/net/api/JSONRPCServerPortGateTest.java new file mode 100644 index 0000000..51d216e --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/JSONRPCServerPortGateTest.java @@ -0,0 +1,92 @@ +package io.xlite.daemon.app.net.api; + +import io.xlite.daemon.app.net.CoinInstance; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.ServerSocket; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +public class JSONRPCServerPortGateTest { + + private static int freePort() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + @Test + public void testPortBindable_TrueOnFreePort() throws Exception { + int port = freePort(); + assertTrue(JSONRPCServer.portBindable(port)); + } + + @Test + public void testPortBindable_FalseWhileHeld() throws Exception { + int port = freePort(); + try (ServerSocket holder = new ServerSocket(port, 1, + InetAddress.getByName("0.0.0.0"))) { + // A plain listener without SO_REUSEADDR must make the gated + // probe report the port as not bindable. + assertFalse(JSONRPCServer.portBindable(port)); + } + assertTrue(JSONRPCServer.portBindable(port)); + } + + @Test + public void testAwaitPortBindable_TimesOutWhileHeld() throws Exception { + int port = freePort(); + try (ServerSocket holder = new ServerSocket(port, 1, + InetAddress.getByName("0.0.0.0"))) { + long t0 = System.currentTimeMillis(); + assertFalse(JSONRPCServer.awaitPortBindable(port, 400)); + assertTrue(System.currentTimeMillis() - t0 >= 350); + } + } + + @Test + public void testAwaitPortBindable_ReturnsOnceReleased() throws Exception { + int port = freePort(); + try (ServerSocket holder = new ServerSocket(port, 1, + InetAddress.getByName("0.0.0.0"))) { + // Release after a short delay. + new Thread(() -> { + try { + Thread.sleep(250); + holder.close(); + } catch (Exception ignored) { + } + }).start(); + long t0 = System.currentTimeMillis(); + assertTrue(JSONRPCServer.awaitPortBindable(port, 3000)); + // Prompt-return proof: success lands well under 2s once released, + // while a gate that misses the release rides to its 3s deadline. + assertTrue(System.currentTimeMillis() - t0 < 2000); + } + } + + @Test + public void testAwaitBound_PromptReturnWhenStopping() throws Exception { + // A stop request is a terminal state for the wait: burning the full + // budget and stamping a bind failure on a benign shutdown race is + // neither prompt nor truthful. + JSONRPCServer server = new JSONRPCServer(mock(CoinInstance.class), freePort()); + server.deinit(); + long t0 = System.currentTimeMillis(); + assertFalse(server.awaitBound(5000)); + assertTrue(System.currentTimeMillis() - t0 < 1000); + } + + @Test + public void testAwaitBound_RidesToDeadlineWhilePending() throws Exception { + // While no terminal state is reached, the wait must keep its budget + // so a slow but successful rebind is not misread as a failure. + JSONRPCServer server = new JSONRPCServer(mock(CoinInstance.class), freePort()); + long t0 = System.currentTimeMillis(); + assertFalse(server.awaitBound(400)); + assertTrue(System.currentTimeMillis() - t0 >= 350); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/http/client/EXRResponseNormalizeTest.java b/src/test/java/io/xlite/daemon/app/net/api/http/client/EXRResponseNormalizeTest.java new file mode 100644 index 0000000..dc3b0f6 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/http/client/EXRResponseNormalizeTest.java @@ -0,0 +1,212 @@ +package io.xlite.daemon.app.net.api.http.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.xlite.daemon.app.net.CoinTicker; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class EXRResponseNormalizeTest { + + // --- normalizeEXRResponse --- + + @Test + public void testNormalize_EnvelopePassthrough() { + String body = "{\"result\":{\"txid\":\"aa\",\"confirmations\":3},\"error\":null}"; + JsonElement parsed = JsonParser.parseString(body); + assertEquals(body, HTTPClient.normalizeEXRResponse(parsed)); + } + + @Test + public void testNormalize_BareUtxosObjectPassthrough() { + String body = "{\"utxos\":[{\"txhash\":\"ab\",\"vout\":1,\"value\":1.5,\"address\":\"X\",\"block_number\":100}]}"; + JsonElement parsed = JsonParser.parseString(body); + assertEquals(body, HTTPClient.normalizeEXRResponse(parsed)); + } + + @Test + public void testNormalize_BareHistoryArrayPassthrough() { + String body = "[[{\"address\":\"X\",\"txid\":\"t1\",\"amount\":0.5}]]"; + JsonElement parsed = JsonParser.parseString(body); + assertEquals(body, HTTPClient.normalizeEXRResponse(parsed)); + } + + @Test + public void testNormalize_StringWrappedObjectResultUnwrappedOnce() { + JsonObject obj = new JsonObject(); + obj.addProperty("result", "{\"utxos\":[]}"); + assertEquals("{\"utxos\":[]}", HTTPClient.normalizeEXRResponse(obj)); + } + + @Test + public void testNormalize_StringWrappedArrayResultUnwrappedOnce() { + JsonObject obj = new JsonObject(); + obj.addProperty("result", "[[{\"address\":\"X\"}]]"); + assertEquals("[[{\"address\":\"X\"}]]", HTTPClient.normalizeEXRResponse(obj)); + } + + @Test + public void testNormalize_ScalarStringResultKeptWrapped() { + String body = "{\"result\":\"abcdef0123\",\"error\":null}"; + JsonElement parsed = JsonParser.parseString(body); + assertEquals(body, HTTPClient.normalizeEXRResponse(parsed)); + } + + @Test + public void testNormalize_ErrorEnvelopePassthrough() { + String body = "{\"result\":null,\"error\":{\"code\":-32000,\"message\":\"boom\"}}"; + JsonElement parsed = JsonParser.parseString(body); + assertEquals(body, HTTPClient.normalizeEXRResponse(parsed)); + } + + @Test + public void testNormalize_NullAndJsonNullYieldNull() { + assertNull(HTTPClient.normalizeEXRResponse(null)); + assertNull(HTTPClient.normalizeEXRResponse(JsonParser.parseString("null"))); + } + + @Test + public void testNormalize_NonJsonTextInResultFallsBackToPassthrough() { + JsonObject obj = new JsonObject(); + obj.addProperty("result", "not-json {"); + assertEquals(obj.toString(), HTTPClient.normalizeEXRResponse(obj)); + } + + // --- parseEnvelope --- + + @Test + public void testParseEnvelope_ValidEnvelopeReturnsObjectWithResult() { + JsonObject env = HTTPClient.parseEnvelope("{\"result\":5,\"error\":null}", "op"); + assertNotNull(env); + assertEquals(5, env.get("result").getAsInt()); + } + + @Test + public void testParseEnvelope_ScalarStringResultPreserved() { + JsonObject env = HTTPClient.parseEnvelope("{\"result\":\"\",\"error\":null}", "op"); + assertNotNull(env); + assertEquals("", env.get("result").getAsString()); + } + + @Test + public void testParseEnvelope_ObjectResultPreserved() { + JsonObject env = HTTPClient.parseEnvelope("{\"result\":{\"hex\":\"aa\"},\"error\":null}", "op"); + assertNotNull(env); + assertEquals("aa", env.getAsJsonObject("result").get("hex").getAsString()); + } + + @Test + public void testParseEnvelope_ErrorYieldsNull() { + assertNull(HTTPClient.parseEnvelope( + "{\"result\":null,\"error\":{\"code\":-25,\"message\":\"missing inputs\"}}", "op")); + } + + @Test + public void testParseEnvelope_NullResultYieldsNull() { + assertNull(HTTPClient.parseEnvelope("{\"result\":null,\"error\":null}", "op")); + } + + @Test + public void testParseEnvelope_MissingResultKeyYieldsNull() { + assertNull(HTTPClient.parseEnvelope("{\"foo\":1}", "op")); + } + + @Test + public void testParseEnvelope_NonObjectPayloadYieldsNull() { + assertNull(HTTPClient.parseEnvelope("[1,2]", "op")); + assertNull(HTTPClient.parseEnvelope("\"str\"", "op")); + } + + @Test + public void testParseEnvelope_MalformedJsonYieldsNull() { + assertNull(HTTPClient.parseEnvelope("{nope", "op")); + } + + @Test + public void testParseEnvelope_NullInputYieldsNull() { + assertNull(HTTPClient.parseEnvelope(null, "op")); + } + + // --- extractCoin --- + + @Test + public void testExtractCoin_ValidTicker() { + JsonArray params = JsonParser.parseString("[\"BLOCK\",[\"addr\"]]").getAsJsonArray(); + assertEquals(CoinTicker.BLOCKNET, HTTPClient.extractCoin(params)); + } + + @Test + public void testExtractCoin_UnknownTickerYieldsNull() { + JsonArray params = JsonParser.parseString("[\"NOPE\",[\"addr\"]]").getAsJsonArray(); + assertNull(HTTPClient.extractCoin(params)); + } + + @Test + public void testExtractCoin_NonStringFirstParamYieldsNull() { + JsonArray params = JsonParser.parseString("[42,\"x\"]").getAsJsonArray(); + assertNull(HTTPClient.extractCoin(params)); + } + + @Test + public void testExtractCoin_EmptyParamsYieldNull() { + assertNull(HTTPClient.extractCoin(new JsonArray())); + } + + // --- sendRawTransaction envelope passthrough --- + + static class CannedClient extends HTTPClient { + final String canned; + + CannedClient(String canned) { + super(2); + this.canned = canned; + } + + @Override + String executeRequest(String endpoint, JsonObject params) { + return canned; + } + } + + @Test + public void testSendRawTransaction_ErrorEnvelopePassedThroughIntact() { + HTTPClient client = new CannedClient( + "{\"result\":null,\"error\":{\"code\":-25,\"message\":\"missing inputs\"}}"); + JsonObject res = client.sendRawTransaction(CoinTicker.BLOCKNET, "deadbeef"); + assertNotNull(res); + assertTrue(res.has("error") && !res.get("error").isJsonNull()); + assertEquals(-25, res.getAsJsonObject("error").get("code").getAsInt()); + assertEquals("missing inputs", res.getAsJsonObject("error").get("message").getAsString()); + } + + @Test + public void testSendRawTransaction_LegacyIntErrorEnvelopePassedThrough() { + HTTPClient client = new CannedClient("{\"result\":null,\"error\":-4}"); + JsonObject res = client.sendRawTransaction(CoinTicker.BLOCKNET, "deadbeef"); + assertNotNull(res); + assertEquals(-4, res.get("error").getAsInt()); + } + + @Test + public void testSendRawTransaction_SuccessEnvelopePassedThrough() { + HTTPClient client = new CannedClient("{\"result\":\"abcdef\",\"error\":null}"); + JsonObject res = client.sendRawTransaction(CoinTicker.BLOCKNET, "deadbeef"); + assertNotNull(res); + assertEquals("abcdef", res.get("result").getAsString()); + } + + @Test + public void testSendRawTransaction_MalformedJsonYieldsNull() { + HTTPClient client = new CannedClient("{nope"); + assertNull(client.sendRawTransaction(CoinTicker.BLOCKNET, "deadbeef")); + } + + @Test + public void testSendRawTransaction_NullResponseYieldsNull() { + HTTPClient client = new CannedClient(null); + assertNull(client.sendRawTransaction(CoinTicker.BLOCKNET, "deadbeef")); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientHeightStalenessTest.java b/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientHeightStalenessTest.java new file mode 100644 index 0000000..ad981d3 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientHeightStalenessTest.java @@ -0,0 +1,195 @@ +package io.xlite.daemon.app.net.api.http.client; + +import com.google.gson.JsonObject; +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Handler; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Height-staleness logging: a backend that stops answering for a coin must + * be LOUD exactly twice per episode (first miss, recovery), and a null or + * malformed /height response must count against every coin instead of + * freezing all heights in total silence. Steady-state success stays quiet. + */ +class HTTPClientHeightStalenessTest { + + private static class StubHeightsClient extends HTTPClient { + private String heightsJson; + + StubHeightsClient() { + super(2); + } + + void serveHeights(String jsonOrNull) { + this.heightsJson = jsonOrNull; + } + + @Override + String executeRequest(String endpoint, JsonObject params) { + if ("/height".equals(endpoint)) { + return heightsJson; + } + return null; + } + } + + private static class CollectingHandler extends Handler { + final List records = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + List warningsFor(String ticker) { + List out = new ArrayList<>(); + for (LogRecord r : records) { + if (r.getLevel().intValue() >= java.util.logging.Level.WARNING.intValue() + && r.getMessage() != null && r.getMessage().contains(ticker)) { + out.add(r.getMessage()); + } + } + return out; + } + } + + private final StubHeightsClient client = new StubHeightsClient(); + private CoinInstance block; + private CollectingHandler handler; + private Logger globalLog; + private int savedHeight; + private final Map savedStreaks = new LinkedHashMap<>(); + + @BeforeEach + void captureLogsAndSnapshot() { + block = CoinInstance.getInstance(CoinTicker.BLOCKNET); + assertNotNull(block); + savedHeight = CoinInstance.getBlockCountByTicker(CoinTicker.BLOCKNET); + // getAllBlockCounts() touches every live singleton: snapshot then + // reset all so streak counts are exact regardless of suite ordering, + // and no other test's streaks leak out through us. + savedStreaks.clear(); + for (CoinInstance ci : CoinInstance.getCoinInstances()) { + savedStreaks.put(ci, ci.getUpdateFailures()); + ci.resetUpdateFailures(); + } + globalLog = LogManager.getLogManager().getLogger(Logger.GLOBAL_LOGGER_NAME); + handler = new CollectingHandler(); + handler.setLevel(java.util.logging.Level.ALL); + globalLog.addHandler(handler); + } + + @AfterEach + void releaseLogsAndRestore() { + globalLog.removeHandler(handler); + client.close(); + for (Map.Entry e : savedStreaks.entrySet()) { + e.getKey().resetUpdateFailures(); + for (int i = 0; i < e.getValue(); i++) { + e.getKey().incrementUpdateFailures(); + } + } + block.addBlockCount(CoinTicker.BLOCKNET, savedHeight); + } + + @Test + void testFirstMissWarnsWithTickerAndStreak() { + client.serveHeights("{\"result\":{}}"); + client.getAllBlockCounts(); + assertEquals(1, block.getUpdateFailures()); + List warnings = handler.warningsFor("BLOCK"); + assertEquals(1, warnings.size(), "first miss must warn once, got " + warnings); + assertTrue(warnings.get(0).contains("streak 1"), warnings.get(0)); + } + + @Test + void testOngoingMissesStayQuietAfterFirst() { + client.serveHeights("{\"result\":{}}"); + for (int i = 0; i < 7; i++) { + client.getAllBlockCounts(); + } + assertEquals(7, block.getUpdateFailures()); + List warnings = handler.warningsFor("BLOCK"); + assertEquals(1, warnings.size(), "only the first miss warns, got " + warnings); + assertTrue(warnings.get(0).contains("streak 1")); + } + + @Test + void testNullHeightResponseCountsAgainstEveryCoin() { + client.serveHeights(null); + client.getAllBlockCounts(); + for (CoinInstance ci : CoinInstance.getCoinInstances()) { + assertEquals(1, ci.getUpdateFailures(), + "null /height must count, not early-return: " + ci.getTicker()); + } + assertFalse(handler.warningsFor("BLOCK").isEmpty()); + } + + @Test + void testMalformedHeightResponseCountsAgainstEveryCoin() { + client.serveHeights("not json at all {{{"); + client.getAllBlockCounts(); + for (CoinInstance ci : CoinInstance.getCoinInstances()) { + assertEquals(1, ci.getUpdateFailures(), + "malformed /height must count, not abort: " + ci.getTicker()); + } + assertFalse(handler.warningsFor("BLOCK").isEmpty()); + } + + @Test + void testWrongTypedHeightValueCountsAsMiss() { + client.serveHeights("{\"result\":{\"BLOCK\":\"bogus\"}}"); + client.getAllBlockCounts(); + assertEquals(1, block.getUpdateFailures(), + "wrong-typed height must count, not abort the loop"); + assertFalse(handler.warningsFor("BLOCK").isEmpty()); + } + + @Test + void testRecoveryWarnsWithStreakAndFreshHeight() { + client.serveHeights("{\"result\":{}}"); + client.getAllBlockCounts(); + client.getAllBlockCounts(); + client.serveHeights("{\"result\":{\"BLOCK\":4790800}}"); + client.getAllBlockCounts(); + assertEquals(0, block.getUpdateFailures()); + assertEquals(4790800, CoinInstance.getBlockCountByTicker(CoinTicker.BLOCKNET)); + List warnings = handler.warningsFor("BLOCK"); + boolean recovered = false; + for (String w : warnings) { + if (w.contains("recovered after 2")) { + recovered = true; + } + } + assertTrue(recovered, "recovery must name the ended streak: " + warnings); + } + + @Test + void testHealthySuccessStaysQuiet() { + client.serveHeights("{\"result\":{\"BLOCK\":4790800}}"); + client.getAllBlockCounts(); + assertEquals(0, block.getUpdateFailures()); + assertTrue(handler.warningsFor("BLOCK").isEmpty(), "steady state must not warn"); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientRoutingTest.java b/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientRoutingTest.java new file mode 100644 index 0000000..b834eac --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientRoutingTest.java @@ -0,0 +1,54 @@ +package io.xlite.daemon.app.net.api.http.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class HTTPClientRoutingTest { + + static class LegacyModeClient extends HTTPClient { + LegacyModeClient() { + super(2); + } + + @Override + boolean useEXR() { + return false; + } + } + + static class ExrModeClient extends HTTPClient { + ExrModeClient() { + super(2); + } + + @Override + boolean useEXR() { + return true; + } + } + + @Test + public void testShouldUseEXR_LegacyModeNeverRoutesToEXR() { + HTTPClient client = new LegacyModeClient(); + assertFalse(client.shouldUseEXR("/")); + assertFalse(client.shouldUseEXR("/height")); + assertFalse(client.shouldUseEXR("/fees")); + } + + @Test + public void testShouldUseEXR_ExrModeRoutesSupportedEndpoints() { + HTTPClient client = new ExrModeClient(); + assertTrue(client.shouldUseEXR("/")); + assertTrue(client.shouldUseEXR("/height")); + assertTrue(client.shouldUseEXR("/fees")); + } + + @Test + public void testShouldUseEXR_ExrModeIgnoresUnknownEndpoints() { + HTTPClient client = new ExrModeClient(); + assertFalse(client.shouldUseEXR("/other")); + assertFalse(client.shouldUseEXR("")); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientUtxoEvictionTest.java b/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientUtxoEvictionTest.java new file mode 100644 index 0000000..733c059 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/http/client/HTTPClientUtxoEvictionTest.java @@ -0,0 +1,198 @@ +package io.xlite.daemon.app.net.api.http.client; + +import io.xlite.daemon.app.net.CoinInstance; +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.util.AddressBalance; +import io.xlite.daemon.app.util.ConfigHelper; +import io.xlite.daemon.app.util.UTXO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Eviction of backend-omitted addresses from the UTXO view. + * + * A successful getutxos response that omits a tracked address (or flags it + * in the backend "errors" array) means the backend holds no unspent outputs + * for it. Stored rows for such addresses must be dropped: otherwise a stale + * view re-serves dead outputs with live-looking confirmations indefinitely + * (live 2026-09-24: hub rejects on just-spent fee/funding inputs the wallet + * kept listing). Whole-batch failures (null / unparseable) must keep old + * rows (an outage must never wipe the wallet). + */ +public class HTTPClientUtxoEvictionTest { + + // Checksum-valid BIP39 test vector; only used to derive wallet addresses. + private static final String MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + private static final String PASSWORD = "eviction-test-password"; + + @TempDir + Path tempDir; + + private CoinInstance coin; + private String addrA; + private String addrB; + + /** Canned backend: overrides the package-private transport, no network. */ + static class CannedClient extends HTTPClient { + String canned; + + CannedClient() { + super(2); + } + + @Override + String executeRequest(String endpoint, com.google.gson.JsonObject params) { + return canned; + } + } + + @BeforeEach + void setup() { + ConfigHelper.CONFIG_DIR = tempDir.toString(); + CoinInstance.getCoinInstances().clear(); + CoinInstance.setAddressDiscoveryEnabled(false); + Map cfgs = new LinkedHashMap<>(); + Map block = new LinkedHashMap<>(); + block.put("AddressPrefix", "26"); + block.put("ScriptPrefix", "28"); + block.put("SecretPrefix", "154"); + block.put("COIN", "100000000"); + block.put("FeePerByte", "20"); + block.put("MinTxFee", "10000"); + block.put("Port", "41414"); + block.put("DustAmount", "0"); + block.put("Title", "Blocknet"); + cfgs.put("BLOCK", new io.xlite.daemon.app.coinconfig.CoinConfig("BLOCK", "Blocknet", "blocknet--v4.2.0", block)); + if (!io.xlite.daemon.app.coinconfig.CoinConfigRegistry.isLoaded()) { + io.xlite.daemon.app.coinconfig.CoinConfigRegistry.loadForTest(cfgs); + } + coin = CoinInstance.getInstance(CoinTicker.BLOCKNET); + assertNotNull(coin); + coin.getConfigHelper().setAddressCount(2); + char[] pw = PASSWORD.toCharArray(); + try { + assertNull(coin.init(pw, MNEMONIC, false)); + } finally { + Arrays.fill(pw, '\0'); + } + List pairs = coin.getAddressKeyPairs(); + assertTrue(pairs.size() >= 2); + addrA = pairs.get(0).getAddress().toBase58(); + addrB = pairs.get(1).getAddress().toBase58(); + assertNotEquals(addrA, addrB); + } + + @AfterEach + void teardown() { + coin.deinit(); + CoinInstance.getCoinInstances().clear(); + } + + // Well-formed 64-hex txids so the getAllUTXOS rendering path parses them + // (short placeholders are skipped as unparseable and would bypass the + // end-to-end fetch assertions below). + private static final String TX_A = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String TX_B = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + private static final double ROW_VALUE = 0.01000599; + + private static double balanceOf(CoinInstance c, String addr) { + AddressBalance balance = c.getAddressBalance(addr); + assertNotNull(balance); + return balance.getBalanceProp(); + } + + private static String utxoRow(String address, String txid, int vout) { + return "{\"address\":\"" + address + "\",\"txhash\":\"" + txid + + "\",\"vout\":" + vout + ",\"block_number\":100,\"value\":0.01000599}"; + } + + private static List txidsOf(CoinInstance c, String addr) { + AddressBalance balance = c.getAddressBalance(addr); + List out = new ArrayList<>(); + if (balance != null) { + for (UTXO u : balance.getUtxos()) { + out.add(u.getTxid()); + } + } + return out; + } + + @Test + void testOmittedAddressRowsEvictedOnNextSuccessfulFetch() { + CannedClient client = new CannedClient(); + client.canned = "{\"utxos\":[" + utxoRow(addrA, TX_A, 0) + "," + utxoRow(addrB, TX_B, 0) + "]}"; + assertNotNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + assertEquals(List.of(TX_A), txidsOf(coin, addrA)); + assertEquals(List.of(TX_B), txidsOf(coin, addrB)); + assertEquals(ROW_VALUE, balanceOf(coin, addrA), 1e-9); + assertEquals(ROW_VALUE, balanceOf(coin, addrB), 1e-9); + + // Backend now omits addrB: its stored row must go, addrA untouched. + client.canned = "{\"utxos\":[" + utxoRow(addrA, TX_A, 0) + "]}"; + assertNotNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + assertEquals(List.of(TX_A), txidsOf(coin, addrA)); + assertTrue(txidsOf(coin, addrB).isEmpty(), + "backend-omitted address kept stale rows: " + txidsOf(coin, addrB)); + // Eviction must also drop the cached balance: without a recalculation + // the address keeps reporting its old getbalance after its rows go. + assertEquals(ROW_VALUE, balanceOf(coin, addrA), 1e-9); + assertEquals(0.0, balanceOf(coin, addrB), 1e-9); + } + + @Test + void testBackendErrorArrayEvictsFlaggedAddress() { + CannedClient client = new CannedClient(); + client.canned = "{\"utxos\":[" + utxoRow(addrA, TX_A, 0) + "," + utxoRow(addrB, TX_B, 0) + "]}"; + assertNotNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + + // Both present but backend flags addrB failed: addrB rows must go. + client.canned = "{\"utxos\":[" + utxoRow(addrA, TX_A, 0) + "," + utxoRow(addrB, TX_B, 0) + "]," + + "\"errors\":[{\"address\":\"" + addrB + "\",\"message\":\"boom\"}]}"; + assertNotNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + assertEquals(List.of(TX_A), txidsOf(coin, addrA)); + assertTrue(txidsOf(coin, addrB).isEmpty()); + assertEquals(ROW_VALUE, balanceOf(coin, addrA), 1e-9); + assertEquals(0.0, balanceOf(coin, addrB), 1e-9); + } + + @Test + void testWholeBatchFailureKeepsStoredRows() { + CannedClient client = new CannedClient(); + client.canned = "{\"utxos\":[" + utxoRow(addrA, TX_A, 0) + "]}"; + assertNotNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + + // Backend outage envelope (no "utxos" key): outage must never wipe. + client.canned = "{\"result\":null,\"error\":{\"code\":-32000,\"message\":\"All addresses failed\"}}"; + assertNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + assertEquals(List.of(TX_A), txidsOf(coin, addrA)); + } + + @Test + void testSpentMarkedRowsSurviveEviction() { + CannedClient client = new CannedClient(); + client.canned = "{\"utxos\":[" + utxoRow(addrA, TX_A, 0) + "," + utxoRow(addrB, TX_B, 0) + "]}"; + assertNotNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + // Wallet-originated spend marker must survive address eviction. + for (UTXO u : coin.getAddressBalance(addrB).getUtxos()) { + u.setSpent(true); + } + client.canned = "{\"utxos\":[" + utxoRow(addrA, TX_A, 0) + "]}"; + assertNotNull(client.getUtxos(CoinTicker.BLOCKNET, 0)); + assertEquals(1, coin.getAddressBalance(addrB).getSpentUtxos().size()); + assertTrue(txidsOf(coin, addrB).isEmpty()); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/http/client/SatsConversionTest.java b/src/test/java/io/xlite/daemon/app/net/api/http/client/SatsConversionTest.java new file mode 100644 index 0000000..bbbf063 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/http/client/SatsConversionTest.java @@ -0,0 +1,42 @@ +package io.xlite.daemon.app.net.api.http.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Whole-coin to satoshi conversion must round to nearest, never truncate. + * + *

A live DASH UTXO worth 0.00050001 (50001 sats) was ingested as 50000 + * sats because {@code (long) Math.floor(value * 1e8)} truncates the binary + * floating-point representation error. The one-satoshi shortfall then + * invalidated the BIP137 ownership proof on takes (the hub re-verifies + * against chain truth), producing servicenode {@code crNoMoney} rejects + * for fully-funded wallets. + */ +class SatsConversionTest { + + @Test + void testLiveDashCase() { + // 0.00050001 is not exactly representable in binary; floor() yields 50000. + assertEquals(50001L, HTTPClient.satsFromWholeCoins(0.00050001)); + } + + @Test + void testExactValues() { + assertEquals(500000L, HTTPClient.satsFromWholeCoins(0.005)); + assertEquals(20000000L, HTTPClient.satsFromWholeCoins(0.2)); + assertEquals(1L, HTTPClient.satsFromWholeCoins(0.00000001)); + assertEquals(0L, HTTPClient.satsFromWholeCoins(0.0)); + } + + @Test + void testSubSatoshiRoundsToZero() { + assertEquals(0L, HTTPClient.satsFromWholeCoins(0.000000001)); + } + + @Test + void testMaxSupplyNoOverflow() { + assertEquals(2_100_000_000_000_000L, HTTPClient.satsFromWholeCoins(21_000_000.0)); + } +} diff --git a/src/test/java/io/xlite/daemon/app/net/api/http/server/HTTPServerHandlerSignedMessageTest.java b/src/test/java/io/xlite/daemon/app/net/api/http/server/HTTPServerHandlerSignedMessageTest.java new file mode 100644 index 0000000..5504a9e --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/net/api/http/server/HTTPServerHandlerSignedMessageTest.java @@ -0,0 +1,45 @@ +package io.xlite.daemon.app.net.api.http.server; + +import io.xlite.daemon.app.net.CoinTicker; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Pins the per-coin signed-message headers used by signmessage/verifymessage. + * These strings must match what peer wallets expect — a wrong header produces + * signatures other tooling rejects (e.g. BTC must NOT inherit Litecoin's). + */ +class HTTPServerHandlerSignedMessageTest { + + @Test + @DisplayName("BITCOIN uses its own header, not Litecoin's (fallthrough regression)") + void testSignedMessageHeader_BitcoinIsNotLitecoin() { + String btc = HTTPServerHandler.signedMessageHeader(CoinTicker.BITCOIN); + assertEquals("Bitcoin Signed Message:\n", btc); + } + + @Test + @DisplayName("each mapped coin keeps its documented header") + void testSignedMessageHeader_KnownCoins() { + assertEquals("Blocknet Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.BLOCKNET)); + assertEquals("Bitcoin Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.BITCOIN_CASH)); + assertEquals("Litecoin Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.LITECOIN)); + assertEquals("DarkNet Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.PIVX)); + assertEquals("DarkCoin Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.DASHCOIN)); + assertEquals("Unobtanium Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.UNOBTANIUM)); + assertEquals("Pocketcoin Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.PKOIN)); + assertEquals("DigiByte Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.DIGIBYTE)); + assertEquals("Raven Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.RAVENCOIN)); + assertEquals("Dogecoin Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.DOGECOIN)); + assertEquals("Syscoin Signed Message:\n", HTTPServerHandler.signedMessageHeader(CoinTicker.SYSCOIN)); + } + + @Test + @DisplayName("unmapped tickers return null") + void testSignedMessageHeader_UnmappedReturnsNull() { + assertNull(HTTPServerHandler.signedMessageHeader(null)); + } +} diff --git a/src/test/java/io/xlite/daemon/app/util/ConfigHelperMigrationTest.java b/src/test/java/io/xlite/daemon/app/util/ConfigHelperMigrationTest.java new file mode 100644 index 0000000..6bc1158 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/util/ConfigHelperMigrationTest.java @@ -0,0 +1,78 @@ +package io.xlite.daemon.app.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Direct coverage for migration steps the public path cannot force on a + * single test filesystem: the staged copy-verify-rename fallback (only + * taken when an atomic move is unsupported) and the constructor's + * fail-closed propagation. + */ +class ConfigHelperMigrationTest { + + @Test + void testCopyVerifyRenameFallback(@TempDir Path tmp) throws IOException { + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + Path staging = tmp.resolve("xlite-daemon.migrating"); + Files.createDirectories(oldDir.resolve("settings")); + Files.write(oldDir.resolve("settings").resolve("config-a.json"), + "{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + Files.write(oldDir.resolve("key.dat"), new byte[]{1, 2, 3, 4}); + + ConfigHelper.copyVerifyRename(oldDir.toFile(), newDir.toFile(), staging.toFile()); + + // Verified content landed via staging; staging renamed away (gone). + assertEquals("{\"a\":1}", new String( + Files.readAllBytes(newDir.resolve("settings").resolve("config-a.json")), + StandardCharsets.UTF_8)); + assertTrue(Files.exists(newDir.resolve("key.dat"))); + assertFalse(Files.exists(staging), "staging must be renamed to the data dir, not left behind"); + // Legacy source untouched throughout: archiving is the caller's job. + assertTrue(Files.exists(oldDir.resolve("settings").resolve("config-a.json"))); + } + + @Test + void testCopyVerifyRenameFailureThrows(@TempDir Path tmp) throws IOException { + Path oldDir = tmp.resolve("CloudChains"); + Path newDir = tmp.resolve("xlite-daemon"); + Files.createDirectories(oldDir); + // Staging parent is a regular file: nothing can be copied there. + Path blocker = tmp.resolve("blocker"); + Files.write(blocker, "x".getBytes(StandardCharsets.UTF_8)); + Path staging = blocker.resolve("xlite-daemon.migrating"); + + assertThrows(IllegalStateException.class, + () -> ConfigHelper.copyVerifyRename(oldDir.toFile(), newDir.toFile(), staging.toFile())); + assertFalse(Files.exists(newDir), "failed fallback must not leave a partial data dir"); + } + + @Test + void testConstructorPropagatesUnusableDataDir(@TempDir Path tmp) throws IOException { + String saved = ConfigHelper.CONFIG_DIR; + try { + Path blocker = tmp.resolve("blocker"); + Files.write(blocker, "x".getBytes(StandardCharsets.UTF_8)); + ConfigHelper.CONFIG_DIR = blocker.toString(); + + // Must propagate fail-closed, never boot on defaults with no wallet. + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new ConfigHelper("test")); + assertTrue(e.getMessage().contains("data directory unusable"), + "message must name the failure, got: " + e.getMessage()); + } finally { + ConfigHelper.CONFIG_DIR = saved; + } + } +} diff --git a/src/test/java/io/xlite/daemon/app/util/SatsTest.java b/src/test/java/io/xlite/daemon/app/util/SatsTest.java new file mode 100644 index 0000000..22b47d7 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/util/SatsTest.java @@ -0,0 +1,42 @@ +package io.xlite.daemon.app.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Shared whole-coin to satoshi conversion must round to nearest, never truncate. + * + *

Binary doubles cannot exactly represent most decimal fractions. A value + * like 0.00050001 (50001 sats) is stored as slightly less, so {@code (long)} + * truncation yields 50000 — a 1-satoshi shortfall that invalidated BIP137 + * ownership proofs on takes (servicenode {@code crNoMoney} rejects). Every + * whole-coin to base-unit site (ingest, send, fees, display) must go through + * {@link Sats#fromWholeCoins}. + */ +class SatsTest { + + @Test + void testInexactDoubleRoundsToNearest() { + // 0.00050001 is not exactly representable in binary; floor() yields 50000. + assertEquals(50001L, Sats.fromWholeCoins(0.00050001)); + } + + @Test + void testExactValues() { + assertEquals(500000L, Sats.fromWholeCoins(0.005)); + assertEquals(20000000L, Sats.fromWholeCoins(0.2)); + assertEquals(1L, Sats.fromWholeCoins(0.00000001)); + assertEquals(0L, Sats.fromWholeCoins(0.0)); + } + + @Test + void testSubSatoshiRoundsToZero() { + assertEquals(0L, Sats.fromWholeCoins(0.000000001)); + } + + @Test + void testMaxSupplyNoOverflow() { + assertEquals(2_100_000_000_000_000L, Sats.fromWholeCoins(21_000_000.0)); + } +} diff --git a/src/test/java/io/xlite/daemon/app/wallet/WalletHelperSendTest.java b/src/test/java/io/xlite/daemon/app/wallet/WalletHelperSendTest.java new file mode 100644 index 0000000..e3639b4 --- /dev/null +++ b/src/test/java/io/xlite/daemon/app/wallet/WalletHelperSendTest.java @@ -0,0 +1,116 @@ +package io.xlite.daemon.app.wallet; + +import io.xlite.daemon.app.net.CoinTicker; +import io.xlite.daemon.app.net.protocols.blocknet.BlocknetNetworkParameters; +import io.xlite.daemon.app.net.protocols.dashcoin.DashcoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.litecoin.LitecoinNetworkParametersLegacy; +import io.xlite.daemon.app.net.protocols.pivx.PivxNetworkParametersLegacy; +import io.xlite.daemon.app.util.UTXO; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression tests for the {@code sendtransaction} "No network found" failure. + * + *

Live daemon rejected valid external LTC/DASH/PIVX destination addresses + * because the send path parsed them with {@code LegacyAddress.fromBase58(null, …)}, + * which only resolves networks in bitcoinj's global registry (BTC mainnet + + * testnet). The vectors below are real mainnet addresses produced by live + * full-node wallets. + */ +class WalletHelperSendTest { + + // Real mainnet P2PKH addresses issued by live full-node wallets. + private static final String LTC_P2PKH = "LWzaDk84d3N1pTL6rN14GCcLXxEjQQ45Lx"; + private static final String DASH_P2PKH = "XuqKo24hcwbnSikBDEdHw8sdk3SbYWkMFq"; + private static final String PIVX_P2PKH = "D5rFeo85qYnCYQyYfn9L3k2No9eCizHECq"; + + /** + * Pins the bitcoinj contract behind the original failure: a null-params + * lookup cannot resolve non-BTC version bytes. Documents why the old + * call was wrong; passes independently of the fix. + */ + @Test + void testNullParamsLookupRejectsAltcoinAddresses() { + assertThrows(AddressFormatException.class, + () -> LegacyAddress.fromBase58(null, LTC_P2PKH)); + assertThrows(AddressFormatException.class, + () -> LegacyAddress.fromBase58(null, DASH_P2PKH)); + assertThrows(AddressFormatException.class, + () -> LegacyAddress.fromBase58(null, PIVX_P2PKH)); + } + + @Test + void testCreateTransactionOutput_LitecoinP2PKH() { + NetworkParameters params = new LitecoinNetworkParametersLegacy(); + UTXO out = WalletHelper.createTransactionOutput( + CoinTicker.LITECOIN, LTC_P2PKH, 0.005, params); + assertEquals(LTC_P2PKH, out.getAddress()); + assertEquals(500_000L, out.getValue()); + } + + @Test + void testCreateTransactionOutput_DashP2PKH() { + NetworkParameters params = new DashcoinNetworkParametersLegacy(); + UTXO out = WalletHelper.createTransactionOutput( + CoinTicker.DASHCOIN, DASH_P2PKH, 0.005, params); + assertEquals(DASH_P2PKH, out.getAddress()); + assertEquals(500_000L, out.getValue()); + } + + @Test + void testCreateTransactionOutput_PivxP2PKH() { + NetworkParameters params = new PivxNetworkParametersLegacy(); + UTXO out = WalletHelper.createTransactionOutput( + CoinTicker.PIVX, PIVX_P2PKH, 0.2, params); + assertEquals(PIVX_P2PKH, out.getAddress()); + assertEquals(20_000_000L, out.getValue()); + } + + @Test + void testCreateTransactionOutput_P2SHRoundTrip() { + // P2SH vectors constructed from the coin's own params, so the version + // bytes are definitionally correct for each network. + assertP2SHRoundTrip(CoinTicker.LITECOIN, new LitecoinNetworkParametersLegacy()); + assertP2SHRoundTrip(CoinTicker.DASHCOIN, new DashcoinNetworkParametersLegacy()); + assertP2SHRoundTrip(CoinTicker.PIVX, new PivxNetworkParametersLegacy()); + } + + private static void assertP2SHRoundTrip(CoinTicker ticker, NetworkParameters params) { + byte[] hash = new byte[20]; + for (int i = 0; i < hash.length; i++) + hash[i] = (byte) (i * 7 + 3); + String p2sh = LegacyAddress.fromScriptHash(params, hash).toBase58(); + assertTrue(LegacyAddress.fromBase58(params, p2sh).isP2SHAddress()); + UTXO out = WalletHelper.createTransactionOutput(ticker, p2sh, 0.001, params); + assertEquals(p2sh, out.getAddress()); + assertEquals(100_000L, out.getValue()); + } + + @Test + void testCreateTransactionOutput_BlocknetRegression() { + NetworkParameters params = new BlocknetNetworkParameters(); + String p2pkh = LegacyAddress.fromPubKeyHash(params, new byte[20]).toBase58(); + UTXO out = WalletHelper.createTransactionOutput( + CoinTicker.BLOCKNET, p2pkh, 0.05, params); + assertEquals(p2pkh, out.getAddress()); + assertEquals(5_000_000L, out.getValue()); + } + + @Test + void testCreateTransactionOutput_InexactDoubleRoundsToNearest() { + // 0.00050001 is not exactly representable in binary; the stored + // double sits just below 50001 sats, so (long) truncation yields + // 50000. Must round to nearest like the UTXO ingest path. + NetworkParameters params = new LitecoinNetworkParametersLegacy(); + UTXO out = WalletHelper.createTransactionOutput( + CoinTicker.LITECOIN, LTC_P2PKH, 0.00050001, params); + assertEquals(50_001L, out.getValue()); + } +} diff --git a/src/test/resources/test_config.json b/src/test/resources/test_config.json new file mode 100644 index 0000000..2520e9a --- /dev/null +++ b/src/test/resources/test_config.json @@ -0,0 +1,1011 @@ +{ + "test_parameters": { + "password": "Test^1234", + "mnemonic": "one two three cake neutral benefit quick hip level mother fine burst", + "address_count_initial": 20, + "coin_ticker": "BLOCKNET", + "address_count": 1000 + }, + "expected_addresses": [ + "BX2Z1wyVEvTLShw6M8tkbYqMNkT39X37kA", + "BZpMJFvhYRqiwNBzGV8xXng8tNjb7Dhybr", + "BkZo1ku7SzyRB8DvoLza869Wq6iRsF42tz", + "BVg2zjo7C9zu8Fp8TKURu9GLzcWpNEBRcJ", + "BdBvunttdSYDb2aX3SPnKpVefRcp5iemM7", + "BW3U9U7xZfWZeRzm6ExA617B1sdmkteR2r", + "BaRmApunQ7RJ8tqwfreyCbFNxmCHtUoFG3", + "BoFNoqKXsCdoZaUE5ihaTsWywVLUGQoYQ4", + "Bqw1r7hbpHtjQ15jMy4MUNXv2KTz6KjoBi", + "BYVm3EpkwxZiWvCsbNyH4xo5h7Be1cezgE", + "BoBKoCqmQ3srtputn2b9iwTzpbyEnkQUhf", + "BYWNArud5GMtAYVaGxSxxjQ2GGkjfvHBGs", + "BZaq21xgw7PhsZq29MxTyd2tMNnrFst1pe", + "Bi6hpXiGQPpmikWJyHRUSssV5VcUbTNmgs", + "Be1F5GXpVEEWHtBaUJqFejdXXp7vKrqnJX", + "Bj8DeuofbLqFjNfq7ZFHMyHb3voZikuPN3", + "BrSmGnu335npq1JDyH69uB6GW9qWzHUZSH", + "BWCS1kNjncGYSsvycWrmLDQscE9QKuNzPr", + "BWzJB8kuwqHCBvB7tmakzXNrdXHav2VvNW", + "BUQLdNSu8oVQgF9TL8D676UeGZTde7exRR", + "BdM9nHx33cQ3GE9YerucbkD4aD6fYJHKW3", + "BmmCgSy79BsMRXdRJyDMxF4FyVvNP81rN4", + "BVoyTZPX1yai5DtyPjv4T95yLmMKPzan6d", + "BZRi5iYTLGHBjHTV28F8jLhj2ama4HsWE5", + "BcuXT8bv4rXLkqsF1yZyUPuqLMxzBBa2Zi", + "BdKN2PuSmKr2Z39R2kmgRTdxZ9HuBeAkzZ", + "BoqPRQSikW5dRKRXh48QhVJXWgxoksoGZw", + "BmzQ6WpwUUdYfvyRaZpNk4uU63MoFnbftq", + "BgQufBEwCniU66y21t6MJTrd9HFzKTZvPS", + "BYw1XLmMCPSPJuiJi99iaH5BJs3QaLPWzX", + "BcJLEpKqKdqmmYwz7gRp7dywvfFb6HodMq", + "BgjN7GHNtBoGvNLQgdTHzu1kT3CidJ5PbS", + "BjzNp8nJzQhnPzjS8TyqB291M8Q7ggMjNm", + "BXEfYG6GXwkkcqGpMrpqM68VvdJUayW88T", + "BiyHaf2WYMJSFAdVsoLaVsqeuiv3tgjdyE", + "BoZT69SDYK2RJpUFib6adnjWfrMqW1am7f", + "Bi13gfDNgVrfWMogNo4HzDzdaS4iRPZrXj", + "BoyL3mUbkAuW9EQvZCojDWVwqNVKfsyGbC", + "BqGX3L6UuPZjvDudwdqnk9sKFZYANXWeWa", + "BWjViWe2J4rv4Vcxjhgo5XYLKrTAKPbpAS", + "BYMR1PudaJKKPQMSFp1WtRsXoGN1HbKchq", + "BaxXKi4VCN2fSjKUcochPmooQXRX727Yxt", + "BXTo2NJ5vjREudppBV2DBgfLD2AvkN7btG", + "BXhmAXT2Wv7iSNY6fp2pxKJXV4295sCASN", + "BYTRA9sUZK8T5Yw4xcHdWa2myEw1FjGGC1", + "BnSckDUaoGDuVNbkKdYHF8JYbsrrWxS2UN", + "BqaVcJauSFRJcSs9n6vhvZzuK2t22SD57C", + "Bexp4DM51HjZdPboKBi3Ngfmm5NY4NsdXV", + "Bq9FwG4yaTE5LtBdMcmgJErXR6P7KmVkST", + "BVQ3vdstCBxJ3t11KkBL2ECj7TJ7Wx9bEp", + "BV4xXTMNfHHrLjf21pUVH16HHVCByzmGho", + "BTrX6JKDEfepRD6XdUDoMKHTb2kufc5z9S", + "BdLK3bXCDj5iXhbVY2oqyPsxmK8ydQQ26C", + "Bc8HCFMkfZEiLg7rJPbYnLDckAaYPqdXBx", + "Bdwq3nKvD474Cgx7h7BkkwS5bFPpNnZD8B", + "Bqz4ebiK4bxYauwW2a7TFcXMyGMz8gQ3fj", + "BmjhQyTbsefGo9maZFaSekmm7WRFonXE2n", + "BjEY8anDdqXju7jxKWXCRbLfxCvZYExcdf", + "BaF3PWL29L4tXuPE4rc3zPHjr5EY6B6wQG", + "BhnfWEuKRcAP4JUTrwaC5Q8sC63pUU9xCd", + "BVdNQ29wxx4yiDd1JGHPyt64EZJ1ndCDAs", + "BWxsT6Bbhv2yt4zUciuDGw9VkH1FVzLFZ3", + "BfyUhBon2jc37nLzrKXD779khSuj8ev9Ht", + "BWpRGThZsQsAY1hm9wxYPY5xatYYGyuVK7", + "BrhtmV28tyYHYFPVtN8aKNWxZs9yHkARk2", + "BphpkQsmTujddnuptGiBqJddBwBSFQpLzC", + "BgyTAypyeK65Tx5wFWAuUkXadcH2sGTXG6", + "BeqGVyi5VTDhighrMTJr2E87R9WSmnFb2p", + "BhjSXy6Dj2mjQjh3Qa85p9L5uph5qMmxaX", + "BYgkbAZvikuKW3rMBZWFJAM4kdx7LhKzzE", + "BmNPfYfmWLkVNK7NNxbTwgn9ET6aQeuUJ2", + "BbwqX6JjxDKqCX6NjdAADajXZiuzAQh4m1", + "Bpobpty3hSYJ4Wf6tPiwZFqLedaDCzhRre", + "BbkGAtZpk5p8cEHcrjUsrXN8sk2oPU6ktt", + "BdQJv13vCsN7i1XaKtauky5wetRcfmvawK", + "BbWdRm2B7GoY6dGoS9BkGbr48KrP14e2df", + "BmE5zyT6xP5L5JbXspE3vnuqwseUVSW4tu", + "BiHvfkQ8hCih8Wr2JAHn5bx37KWGb8iYu9", + "BgZ2zx5DQ3q9BQJkBNnp26P8uqjD4JKrKy", + "BoHqyTvh8BuTvtgoxCXwYow9pRMFJdMLir", + "BmyXXDMw5KXoyuCX9Fv5kZPAX8QHhFhnUb", + "BW54jFkBvasDPy7ERb54RvP8e7XwCni51r", + "BrBr96WhD5y1HFJGzSdR9ZUjjpSpiYdwaa", + "BV8eDyYEQQMcM7jxhcYJgsA4G6uGDC8XFV", + "BfFdh9CHK7f6SAEDio378DRgadFnYwRhRn", + "BoNJzPvpmCYw6btYecbWUhfM2sabbSRVuP", + "BfQkCpjFbNohKogj8e1kkaDBmnWQuTDG8M", + "Bm3me9C4JGu4V3QoWyYiXN16uyBZbMHySa", + "Bf2PNzgKnXGPL5DxPp3X24SQFeJ8czK38T", + "Bi2VMxKACs42pBA3nDta6ku1FyazFy9Y5w", + "BkC6ZLNU2JL7cvbvVSkeZUKm9DN1QdowhY", + "Ba1rv17b87nZtN6xVHkyKpA8Yzc8whrjC9", + "BqbHmEj2G7RV33RZfupnSsjDJ3mJeqjHXk", + "Bc2TxWoL2f4tumDRXGouBCDFvVmQkit1nS", + "Bg5rhkhrB2SwzvLk2GsE6Gy81gB7dRGMnQ", + "BW7GqX7XLfDYNJec6VTxpngTctewyzs9yu", + "BY5DA5LgRJYMk3amUp9kYanfjYX8zq6Py4", + "BZraF6F6V5Zz5wf8xf5ZpvPU3dSMw5USUn", + "BduWHJLQa4dWvHpQRpmHu1DzkqPFT3aG92", + "BTyoKQqvGH4wnZG9iMn8bJcRjHi7DPuiSN", + "BeeLGACCd9jA6MVSeiGnUQx3cdBMKCXqTt", + "Bi68S2s3oJLVT7Bg92gDkCfhJFCUmPBSyw", + "BUckFc7MtgT1aEMv4YhXEwEeJtQ94GWBsy", + "BUdqy1vKDweWXm8deDoCb2WaJeLNQmrvLy", + "BdbtqVPhXzPdqbpCaV32HThtvifuT5XzEX", + "BdNhkERVdvRVF3ArZtLN2kGVNJqHjGVq2n", + "BY9b2gPpmF16TwbW3HVaUVUQzd7FrErfwK", + "BYkH7qvaK6KhpP1YFgibvvdX7b8bUQ9z4r", + "BXaB4PH5xBb2ggW7HAkWMRVAq9wC54twov", + "BrMY8pkfCuWudccGgMbpfUyCu1cT4PomoX", + "Bi3Ko3NWzDQ2U7dMUk18ZwoyhAARuysyzd", + "Bn9Q4g6MYKLrmA77TTsprtpf3ZY5sBwoqa", + "BkFtJKqvxQiv2Ef8kr6Px6d6qGHTHcuMER", + "BkBMFuvmZMM4K2PMeQyKhdkUYtSRieopDY", + "BkXJkeShM5EUBAxjFjTdVJ3TX3tuRFdreM", + "BWrp61jzW767VE6mNcA1NzAReWBoHbo1FU", + "BWFBHUzeuVG7QedDC9RXTq5zHemFn4SUwF", + "BWqba5AA7bGSfQA2uimLWyu6SzwzGNzjBb", + "Bn7eHiYtmxX7ZLctME1S8w4vPWt2H4ALNe", + "BgJJiA6dFXUkciN43typv8MxSG3Br4gD9B", + "BZxrGu6wvw9iXsDBLBEWzr8FvEoxVZWhbc", + "BmhYCrJwUZXHrmCynuNc9cSZ4mASLP23cW", + "Bmd1VqwtDDgs1F34FbVpouXQf256BBh9it", + "BnbkSLzf545yknor2o7r3gHAT5Mm4uiYXL", + "BnrCz42h4Q1t2Tw2mDjYdBn3iKqkrwpoGv", + "BrQx3DhmDMmr8QC4RxpoZjkwivoCs79Qyo", + "BgsqVZDQM5gkur5BprwNvTMzmqgP5sYuAW", + "BgiG9YU64yzm18YgQY4h5M8gNwMqLmSLTV", + "BokzVTBwCCLoXC42YP3f96gecmSfWbrCDM", + "BZfzKGhTvqbSwjWxFdsX6c4jGcQFaZkePx", + "BqWgRsAk4u3HCHVP6FfnVuEGFP6i53W6eB", + "BYvemh61vU3waZv4BuNqT15GYJ3SgDpbXP", + "BnuzbT9CkPULxXDJejgMmSXgVQTAYYeGqV", + "BmCsKs2TV9T8VB9846yL3tS4AbJJ6rwKGt", + "BYdyQcyDsK45rZoKGL14siTgjPPkhhUx7B", + "BU5v8RYXeBjZzVbVvAwrvrhMuNGYERrcjV", + "BcNBggtpMZq9xQKYPs7hRp2NAeEZPEUc2x", + "BoQ7cRNm92RMy6Qh7F13ViUdwMukgFr3Tp", + "BcMrMr4YrvapCGCg3rmsa8uH8sAF1zqnM4", + "BazCXsc6CiRA3vihaBBnAzCvu7PJJN8pfD", + "BqFsrSSJZpenffvznJiUat25oe2L8xBTpU", + "BfFD8feXuGesCgQx3TPn9Z9RVnyqQUMrfT", + "BptAPzCrhDHY5Xk3hFLhTJAPKx6hXdFjn9", + "BUMZJqXYPUuxUtT436gpM6bdnyLbrQmRNY", + "BbhQ7w6u7hTWQ8GinVzMmrRwSfyD6656gE", + "BYJYz8eKRYCRkHUePoYYXgk4aaFKxbR1a3", + "BeJzKouiER9CmrfqCJP2Ciuh7JCqL8VFJT", + "BkonyZKBqf6To9VMHqq2VnntpD2SGopLPt", + "BnYDfJ58ghwn8F98BSmaGaxfKZ81i1LWPx", + "BU56eDu2eLeedinWo3Efa9wcRy3fxP3Nds", + "BfdA7cVwrv47MMrWPFmgiJ9NZ2i4cinsNc", + "Bns8Er88f7AiUxYJjic9fnaqfvxM7nWiRg", + "BpzCCE81n29624jpk8377yGULGj9c4d1o5", + "BfZaMXGV1WTP59rPUAu84s28ivYoqTFeGy", + "Betdek2Sne54Nx5tKZQdEmcF57kVMA2f2Q", + "BnS34d4VSS6Q8HRiEMzyw41TsDHxtMGM5o", + "BaM6MqSb5fGvWTLJSxsvgvEZyJY42t1ioi", + "BVbQJJE4qEbVz9d4a5zgWmbzZMDqsVdPrj", + "BXP6Y1K3EwnpfeQbDBvHoDhiuiMfynsNXy", + "BUMW6ZqQbQnWkPG62UhkyBifCEc7QhcKFB", + "BZuiUTnG1qUpx1F3DLiBpZBJgALDFMiLea", + "BkMQDtq4XmBmrUcrqAJD8hsudiTD2uC5vf", + "Bb4cASKGZZbnjWsJMVRGiFUsoaP8kFBfGZ", + "Bs4tK9rmax4gaL5eHEd1dvGatxJoDkk8FL", + "BgBD8dCTPkzkz5TBFhJZ17diosdh3g1rE8", + "BpMVXtziMUJh23WKQWf9MBdYzDUhA74XJW", + "BbzfeseYs5Cq2yGFQCUN5QQf4PqNkDFaac", + "BYES3oQAcobU5G3wFwu2MiYCupJ7e4QyWh", + "BXuX4dmCh4uVnkdPdFm6p5u5rcJVE7mbbe", + "Bk1f9eD3SGDYgELVpKtVyJ998B3DZbXGAr", + "Bnk18vdaxXUEo7hjWNSsYDsErwRf7gJR6x", + "BkwEcwW4MWbaQewdkFQVipAHs16tMrhGAo", + "BbhExczGVLFemNScqp5fLJwV9XeRwfRKXR", + "BazQqP2aM5HdHJzF5AgTxLo1R3qPqRBqwu", + "BZi9qC42ti1XoLUS1Z7BYL5f9cHqAYPPto", + "BokMJMbBChPQZ15txZUkycQzR73yjqa7ev", + "BVTCSvN2n79Rn8Y2pMfN4U16JqWKi6XUjj", + "BYPeeykCK1ZRu28M6y8eXYHSKT3cVWcZzx", + "Bk6GYbnBZT2gvQrCnMoUWu9EtKxYYT2y6w", + "BcJ95zwqSQVfXrefJ387PvkRANsX8aEiGQ", + "BVNphLjDhfYEGnLjYGwbPMeFDRgLHYERpP", + "BXFnZvVMkEw7KgZLrgeyZjXazyS7seiPGo", + "BmFWRiWrs4mLPnKq1hWKxALde2SAEYtwbT", + "BcVja5CAKo5YbYDJDXyuZfJw2QhsxrXrF9", + "BbsrBzBAcsRtAdGRS2r97ox6cUz16oyYhe", + "Bkh6GhgXKw3MHxCvo8vM8KPBRWf3emaDc6", + "BbEQJJpiaAF8N7u1TSsgo1P7pqJbFaBay6", + "BrM7zdwPEGTGDKFa487hbEm5VCMuqdQNmZ", + "Bc2gscZnDE3R56eh9Nio8BsY8BSVWHqjhH", + "BYK8dP5gGo9quJGxtBgs8XV1RappgKQHVG", + "BhkgnuYXwNaBrh9FczXoZbHVjgitu3rTYm", + "Bp97iLBue9yj1HfQuzyM5ZRUtHzGVK6xQF", + "BXPVknJeUcxXcwogB3Fmw2UPfreLL3rcQ5", + "Bhtup9zqeBLGGfvYTXB6XUhXwoPMXRLzQp", + "BdM6shNPRz7p2G4rm5PJruLxcjRbP4qS69", + "BmEDsHudJ9LqFbAMJfePNMhh8AmTdEVWus", + "BZxLu3x5X5xA8NwaMeQErjkRQiLbnMMnxE", + "BYzCECzn41BKMBbuVGTM4zPrvorgfiSmLM", + "BTtiV8u98yffM868fXUjEXe6kKTLymBmXo", + "Bh3CEVanL3uXWma2i7AEj6p6e8nmRiyZAK", + "BapYEFKUZFUeKYSkYyhCT2E5YCqqX72uoC", + "BqvsRszzvppJqozz5zWStkGEdzCcM7EQe6", + "BmaEKYwDLqh2CmieB5CXwm8ca5Y3cMWS76", + "BYyXyaPiBF1BnyPfiWqVoQWbBTUgPKoX6q", + "BZNpWMMnv3pjjTrJu33j1tzSTVwzrLB33N", + "BkqDH32Bah9SKox87nq7bEyFbLBTLi13cj", + "BjunQGceyrhykXQDXCgKHuws3NZgHKbq8w", + "BYH9zCpvAvT3KinDtewCaT1SmA3qEzyWSp", + "Bq8v94DLJA94L6k2NzY1g4N6z7maTyTRxS", + "BkqaadQVz4QwX8tMPrspkfRdUuF9rokQ99", + "BVVdzrHPe12Avj22V1gewhoYf5JhvGVE6p", + "BorTVfwNkbgpNmSyuLkvPFHi6obHUyq7KG", + "BUHSTa7QeBi7jEi3BFUjKdPwi7js21EA81", + "BXSEqrfLHHqViYtAxTgy9ZRim7187KiNPP", + "BepQgbqr9yWz3nEnzDBDXb7VoaZKqq6jLF", + "BpHUBeZrqp1oFpetkbcSA6etfAwkBeJxQC", + "BYnitMuKkQZUmhPYn1HrzRC2q843TJnDqQ", + "Bi5MgckPJa9jhHcGdJafdrrK5a36L27gex", + "BpW8FSxYJsPBB6zajTp9cVuip2S1xYR73f", + "BaWDoyxR5GUnE2JcrNUVpbh5jfT7SryLQp", + "BZeUF2pinhBLLMp3DA92KL48APyieUNLWx", + "BY9BdBJnR2pWa9cJj2Vag3gg87oQJ9yMHz", + "Bs6fZvSQ6BSyx92W2nxaVk1qNJBehKsrHD", + "BXFyzsHDAK33J6UFUPB71w3dBjppmJJJTN", + "BY54V3kfWLicLQXPe8wKi3jbxp7r7mfQvq", + "Bb9cfrd8SU9wAFrEsqFfyTW7NNo8nASXZK", + "BZv5BRVgqhk1hRkSKtyaduWzzaCJ1VRwMB", + "BXaP88HB5uk5vrLRY4cKeYvjawP3jaW6Av", + "BhapBL2D9wuwtna7qGmdh7WNxadBTBzAkD", + "BioNyLq2BgLkLBLwucRm98VLPJ4AbFp29V", + "Bc9jpNgKcTVVRGboDecreUByFwAdWzfLxk", + "Bq2ELYgSHPnwCHDcKkCF3XM69jvc1V7gyz", + "BmhsGU2hdssp71awB9oZyYwLPF75wNTDkx", + "BnhwvcjoMeTKryBVrMLgECopEGb79NQe5t", + "BYFdFszJQsrm1xjwX9ZTEStbX9ELQE87UA", + "BhBkKRn6GGXUKyFqBM46DPeVLaBRe9xi9o", + "Bhd6aubBB7zFZ8T6ifhgwPBqB3K4KHFgTS", + "BXUqELPAqLbNGF8dbzbKmg6NNcUBMC6zHx", + "Bm47Gz2Z1fuGwTPtdkhZqVRPNTqtVfFhuW", + "BWCj2MZvP85MbdJgkoA1CrnRskm9z4veX9", + "Brj8p9gAhB9aXFyKxGbABYkD1hWG9UbKN7", + "Br2s3H3vrCJKRMRbdNfPiLRNwLVqQKpy8R", + "BmyTL217DWmsr4fMCNatGS3EufRXnqPcUi", + "BWcDsCZfuWArGteDbjPA5Ch7U252Dmys6f", + "BVNU8GKmpF3hEZnBSauavbpet22eeLW1mV", + "BdbPH7wGqZ6dp2XWNQun4m1zv4kkG5dc7y", + "BjYuoG45ECzEWG6cjgJVSJtdV2oPCiRh2G", + "BYT6xCcFtfPgVHRQ6iDKtumes1GRf5Pte2", + "BqzLv86Yxu8RDdUvVJGpyZctjibbZtZ7Kk", + "BX1VYsmFwPMxoBTk1gCBsnfEhG6EGGozNs", + "Bg5ATyaCLGm9uUgnBn4jTTMeJ8GDNBpRH3", + "BoXH12vrmc83VjBSLf2Sk5LuyX5uAbxTk8", + "BnLbRFiCX27A6iSQRghDpmqXacU5JFaQ7s", + "BjN4TjumozuDuVNFzXsJCUKn56SKTj2Zix", + "BaFFTzk6BQKZ4aJkCh48dSdCVNJHc3Lw3v", + "BfxB4svJQzKYycR6bSg3ddVf1VspTfMCkZ", + "BeBpTVzYG9jxbScj4k8qXcoSd7HXPu7QC5", + "BYzTq7y4k9n6LhSdfBsGAT8ui1tREyeTHy", + "Bomfo1DDLeUpqMVyUJYZ9mW2N3z78PXFm6", + "Bb1Nvexe2j7ckq1ueqjoUkpFFitT9zuxzE", + "BeqzqpJHJTDwXDR5UJ6xFvzehpQn3EhtCU", + "BpM4Di5xUp3eRxGtp1MxUT8MjXN9CfKoAf", + "BeN9hUaS3fBCmQyZcdBgcphjpWQyZLETED", + "BdQB1DS2eDQAttZHWe5YSbQLSfHqz4EJX1", + "BasRuymNYaX4GWGks25qJfwHGUyYzMzV2F", + "BcM2u4ayN4USz18vFaA3GpmW7jVtr64Gsd", + "BXndLXBiqt4FaviB7ykeJwjBom7hiWmaTc", + "BkFyiccDn5ckE961GzZLub72FSPQjAoBKV", + "Bovm37NwaCRAp3iYuYEGioq9ogfLiDQc1z", + "BWcjdP31WJNWk5NmAFBo2cLmTCKnyAgKjf", + "Bem9oYB1FDCYxRDKuxDkWuAo7MPnwpHZte", + "Bf5sj6uqh9GcSjsTrMkD1Hq2MKUcjj4RhL", + "Bk3jguZrP8gWcEwPYPBax9B5gW8yncFVYL", + "BnWi33TRuzRfwo1cebAXjKoTRbtZvKgama", + "BkuzsnBZoXJe4qaW9Re9kCPfqnvhufP8y4", + "BrJpao388CytqvJ8y8NfiBGAbsJ8RShQDS", + "Bi97ijrcEMASoGmb2uTtpXBraNaZFYfPRb", + "BjVSpVA2pvWcy2mrLUftHEgzQM8tsxMb1q", + "BYzwALNQuwQH6yNPXDQ1anR7PthncSfbLm", + "BkHzy2nzZPuJ12vHhYaRgfFTBjA5w9bRdi", + "BgGMWn16DpSUsbDDZeFtjXkKWrzqF14Uco", + "BU34Y51S2nVhPmJQnch8BnSPW7Eo399vgc", + "Bqz1tsr2bAd4KDUXqPhSHnS6yhSEftyTng", + "BaaKJ3uP1vSMZVHvmerLvhr6DQouZMmBqX", + "BYHbZVCt8tutj8MY4aNrtfGe96PHPDGRkc", + "Boe2Ekf8aHoEgeW4F9AXnQ5capkARQEBdd", + "BoeVhmaPT8s3ivk4bpe6GDK4Xp4viXABkL", + "BpVtpo9nENwxqycdxgdgJLsixmEuT1BhXf", + "Bad6bYPMisHHCxR24e8LJ1VJsd3GuX78DG", + "Bi1G7d4e58iDHDVkrpRnCnYzdmwePi8fR9", + "Bqe9pzjEp5KxSxaiAy5VoMgDH2YarZcsGu", + "Bnmt9kziztuMkNHdbRAhb9AvdqtGLgyowE", + "BWFGFm7EWNPGhuyPXzqv6wB2F3n9ofjWDo", + "BULUKu6wgmmqwZZUsA6acZVYZxZ1fpHrmA", + "Bitus8X5mvzMu55VETDiUJyV7M1pxydfpb", + "BgSZ1VAgYkr2j8p2BQzHsN5mQjvyzgmxDS", + "BTvQCRzPQLdffZCh5GzUfjY714JPeHBtko", + "Bn4kLfWYgSmsXBWEyb6PzXXF4BUMiDQaLg", + "BkRbtEN7eBm7S6dGa14yc6DseFWgieRPXt", + "BmXeYnHCS6Hk1qLavpTChpSFkZPyPBNPVn", + "BmV8ZZ7MXGEK2QM3oPGB3rzGSuNJ9z2CUf", + "Bi7EEMu6ELoYdR1RzrHXMehDinC5yWjaiP", + "BaJUZdPwLN5BZ61VMwsgRYe2kJyRw6AiJ1", + "BeBNRHf77CM68e7K1dDMVtJzsw1oFiGx1j", + "Bgy3yhDB45DECPimowt4sSkXjRER9wwJCe", + "BhYAmikHCeS7ctSTEZ9MfhgdnMgveXPWsx", + "BiNCFhwACYvegXso3Jaj7DTea8i9jxVRQC", + "Bq2u6YJTy98jZYE8KRTU4qVuxBPy8GFFia", + "BZYtDjLvVo5mCjpg2h9vwRSp5xAbq7ag4h", + "BgFQcLVtBThjQGMJKCjZBxaM3gPGLDiuF2", + "BZn4NVbtbZsvpUwMpjW77BTTrnYAgjUW7e", + "BhcoZboDkSc6g7M1wyx8SRcg52PEkcTRMN", + "BouUdfg7YZ3wZ6czmKRoF1roCki3JZZZjT", + "BXD1Qu8yEeLqTrD95XyYEdG321gftEyV28", + "BaW5gG54E2MsUFmdNbav8NaWYNSMWBLitX", + "BnjoCy14oHBvNvWuNrmF7Yd49u8ErAEvF4", + "BWds7xz5sF6HN48e8go5SAtatijnyh1FdR", + "Bg9wAn8reuAjnQPknY4ATU1hD73f3ZiV5r", + "BomDFea4bsf2dKbBeoHFnh8JZ3FKWo3yCH", + "Bc5Nygx2Gr2Nkb71v4yvjTLddgFKiCTJSH", + "BitLphGdYDA9X8f32gkF3UwJiuDs7mkKnf", + "BhzXkEYjDJYeoGTNomG8f7odDQNATuoiK1", + "BYctoitrJ9BFxExTmQJx1TiavABGboJ5Fm", + "BeVPg7yRnc8jYu84juZUzYmXQ7GouG3xsb", + "Bq2Z3dXX2SQqhLDXJNNT9YoApytXuVtpgG", + "BY3TkH1NNGL4aSoBhBWsQvmkroiY4i5RzB", + "Bb6GWWcRLeeyXXkq67nkmCGWgNsjeEtrpz", + "BiMDyViEUamPBpQJtqfqhaToBg92VSALbd", + "BoVudEkaiDzPsrNayPN7JNcDQHQVQMFBeh", + "BoqZrH22m3u15APVstMzbJyK3otyMHk15G", + "BU4GBLLJqdyJS1a6Ua5CnqyZtBi5m1F63H", + "BrbqF2Cga5bcEHyAnGujy77iGc2GEQan1S", + "BiSbBVP88mnguHVd8DE48WEyzTRdhUbzMp", + "BmCBts7YnFrVv8fmjBrcjHatMzDvSnd9GX", + "BnHsgvVjoVXCwJUpfqozSAMNuFEpNSucjQ", + "BmmjKNg4iKhFRFD8NYqhtg8U8Hv2VzV2DA", + "BfEJXnf7wq9PRCXBCmvoBZNKFpHCd5uASS", + "BaFLARJZufjhrnvjHoB8QzmL6HZY7ThAgX", + "BZ2nqe998paZgwNH2WZCMShpEFRPh1eg4G", + "BgSX6CYpRAAwHuEhfrQr2m9QzqamHhfbv8", + "Bpz8Q5CujyJr2xAV7DCXrZPFKAekkX7hVe", + "BeAwYwKQnEqBrFNY82hAtW6bpjn24i9YmL", + "Bj4eTaig89iYhNU6XWhew841pJeJLQKktG", + "BrfeQgrfd3LNj2VTrq6yH9yG2aD84mgJyB", + "BhuYSegzMWMUNZzYRoo41JFBdaCTNvUcg9", + "Bq5bJ8VWnLkSYZ5LkLp2dtWm7nFf3v9EhA", + "BZ5VUsyhzjzECk2h9V3H6dUCvEgL71VaN4", + "BeQQdVbarpFuA2va19DxGjbYsjBSyNG5dZ", + "Bjobw8tVhaqCzYvWGn4SuKVDjVSy9YpDtj", + "BitxM7bBzNqPKtAUBTFd6MEcKvELbgrSGH", + "BXeLeFKo4nsdBat4k3r8CLuEvofE6HCT7i", + "BqEMvL6w71Cy1AZHjFi4bBfqDwRSzbyA5p", + "BWSoVRhQFf9Y6pCJsAt8pfbyLU6qH47DSK", + "BfgNQuyjMhCzRTYUUu3Dwp1Z9tgay4vTfp", + "BVbMHSuNN7nGcwAD3pt1fC7BJsCuossFtx", + "Be2KdCA1yw7oQm5p7mJvvYYSirrffFDLFA", + "BYKAZJKH6u6Y8eEd7Fft7tp7XW6CdcNUt3", + "BpBikpgBTnmLyq5ftLpRTaDpBKC7Jdj6ks", + "BVLb2ZUUeNCy8BD22uxo8zgUaasTtv9d12", + "Ba1araQbsFYmUMLpn8caAyKZ5iRdZ88Cob", + "BnS2vXKgA3btMF36ryxeEKN1MCeoJPGnaL", + "Bj2Jdq5qy5WfZKHgxPnK24ezJvZiCL4QsT", + "BqKA5XQdCyuH95PPYvhdwpHnk71HfihtRG", + "Bo8rLfRPVoQwEguJXFdh4nkGyR3XpNSHwV", + "BjL1hvNDFpi86Kp9cLim7DuaRbEVKBtSKB", + "BneywpmhkEPYx3c2Yy2bc4o6U6JYFoxq7E", + "BpMimTx8Ao93fz9Lu6QfeqsZw5Lxf2ripe", + "Bi5wpb73KeULsq6jJteZqtE1UnAPudkwBQ", + "BgJM6t3c6oH3E44BHMeA76RTbS46ceh6ns", + "BYM2t542hD9p9shnFLmxzNG3TjoAmiSXQh", + "BobrBCNEoEPa4yfEnPWv2tcGsvZPZkQP6w", + "BfJ2ctzn7gKFHqjXbD16oUzKBawgcZpcfS", + "BgAiLHRX9HcWYE7qY3RPHxbFAkJiwBZP1H", + "BUFQH2Ykc4MJHUuoZF8UNE4CpnWbY4Khzt", + "BhXriJbBmjgoNQiyarh42Dcnjs2VnazXQm", + "BXPVn3kaD8hitYpXcQEMdyvDrZKU8CtSYS", + "BpUpvJGrKPfyGW6Tnh4yD9KfZP6C6dXics", + "BpAx67uqbjKDmhyaMRkZDz56zASq1Vn1cZ", + "BZhyehfEA9xGeUYUMu5AECZreknPKG5JCL", + "BUvw3RmeMYVNs1PQxS9F5GpLhNbE1zQb76", + "BZpx5Eurw6ftgwPQWXkfpTJrCKP6YxwL5z", + "BhCxuTaw1t1QfAwWtHPQaKVfzn3L1ZVwo8", + "BqcpmMaFfmr2yGhcSZQAfEpzpxmLbHRYsD", + "BUSz5sXcgMS6o7fR2qxwyiXp7ucRAd31G1", + "BZtXXb8Vjx7GmgFEEzVS5KoCiDxHHvPgVo", + "BU7KMb9KEUuXxwjTPq2SgDVmAadFhZZ8iv", + "BY57SaRXupd6YC4wn64veiLH6gJE9c5jUH", + "BaBXtAt5n2zoBoaJB8s3SCGCr7ieZd3spy", + "Baco8GBiMvYFER8zmzKodEQFjuSb5sS8SG", + "BrjQHK5PKVRp9evsyAP27akV2AwNwjGfZC", + "BhNQC4ekebNeCZs3hrPJzBMTCRxJ4i5goK", + "BgEczdfWyWJHBf3ZRu79C2zZ4zistCCEUX", + "Bb9j6g68Yc2mCNRb66jpMrNPnWFCJUpKs4", + "Bc4NyhmkRAZedrZdot2FMj7ziTeSVWwiQ7", + "Bpi4qjqhVjkKcz28tbfWWZqYnZ3HhuqT8W", + "BfWmvYKLbyUPYvi9aioqDqp9dFNHTkbcig", + "Bbb7buqNUzWieqjuxiBnDfbepNWCeQ7TCS", + "BUGbY2E3sQCTLyL2bwKriW3Z2VspbVvwm7", + "BeCuiNx8vJc63YHUrMne8vxbKjH4DE5eHR", + "Bs7jBCrxLUSC9UgQj46iGHkpdkibmBeRiB", + "BXtyerD6EPZ3UAAuMgmKrgPj4e1aXxvVAk", + "BfUCb3PT6MW1Yjruf5NGZ7ZTsk7MBaCcFY", + "BrRVoGUpWyAsuvC8ivRHKu6VqRd1skDkm2", + "Bm2Gxw1bEyJQkkHyEcYLJfGBqNcZDdVXsT", + "BeZK86R2WQXALMourEs9XFFyg4nEYfXa9q", + "BTvvbDra26EKJDLpEYdBJecaFbF657dwrv", + "BhrSRUu41WZvoEpJGQ3QQA64QvXBTVGnge", + "Bq79KCv64fG4T23HUwgM2XXfpfTBEjM9MZ", + "Bmt4X2EM2sRb2Dh8usY5txEBimjd3gtouS", + "BmfmYocYYkHpK5YLL8bo4WnabHKpTYWkVK", + "BdtEd8GtVvNY7KrroUAEcroJbijKiKgSew", + "BjgFEU4E4EMDzX88GWQgVtahVQbmSCwGmp", + "BcbfSq1BNwFbiurEyis5xEz8NYCheMmMGg", + "Brv9UrhHcPBVdScWCSFCCc2qfidwind6Cd", + "Bkrvo8iNBPQm8axRg7BSFjs95X2vvWYuA7", + "BcaYLFeMfZrwcapHxDdWKPdXynWvn6HtoL", + "BWXsnsFhJWN2hUWGcqTRahoSJXk6kQr3Jm", + "BoXenuUCJbq8xbwHJoq5y3zNcgk6VjVZMr", + "BqsBJMsTuXyjjEB6z78xxVHS3bhtEfLeGq", + "BnciVfWHi3cgJCuPwk7nxVoQ1JVV24K6Mf", + "BfBybvD2mSYeeB2tav2CuVh6YF1F39AnVr", + "BXQEB5Lq65JtnrnENr9BvGKr4zHVYDefbS", + "BjKxve9jB4N8UUxCm2DxdJKGsTC3dW3zhH", + "BWXa54VHr7EB8oqtzZXYzXRbBrGdXYXtWj", + "Bob4HEQp5CR9dj2AQLDh1p8g5V4eDCNY6R", + "BXAtMibRu95ZQzWAqRyJUUjAMAFhyA3BzP", + "BiLon3yuudA6vyToZGfc2LtEw8q1R7XJc6", + "BnPfFJ386zmrdj9ePaQKeS4B7uGf8pui3P", + "BpFT7wCHkbmjWHTyveVLK4rSFDgs33iCi7", + "Bn4Vt1cHJvd2eEzfPnghK2XJWrZvmf6iQt", + "BnHAfSXWr8wdYTehDjCqGudioQnGBC3Uxp", + "Bd9FNMRPo1q6wrehzJWAxo1Exqrgy3MBei", + "Bi8cLaHLNp3NDRZYwrZ6oGyt5XgGuy6Mmo", + "BUVBJwXuZb6QNaoRamAcHZvGccZdkamK3D", + "Br6bJe4iftJU9gxg4vso8WsjUaeScGGXtv", + "BpUvcCu9GTSsfBFvmUY7iQHW13wCCzBNSt", + "BkhrWQj8jMroSdCnHmYWQaZJGmiSpREcT6", + "BnfFsarJWXHKRAUZvYpvan7nAs9akyBcqk", + "BYRsXqqwi43P44Fzgnrx6uWuwCziEPZ89d", + "Bcwo7QvVBTsSfx7ygxYvaviBfjfsvd7DCw", + "BYzGADtRL7Rp712byLBWVrRVPNsqw2scxT", + "BdY9JKkVZTXc9aFFQQPUXMzpRUCNPtFhou", + "BgB17dQmsqTsSJ8a8ygmCRYDDMer2CzuAq", + "BXQ8tykJKAYYxDmCoxk9Lo3AJPig4CoHmW", + "BX7YyRPRbK6UVYgvEHiBKjCXbj9ggVVxKz", + "BUSVGx1TNbdvFsqtRdzPB7KSj1r9mx2C4x", + "BZZoqZ9AZxpYoCWguYiLNjSLQB6oAS3XJm", + "BWZRRrftVuw8bM9pA5Ks1yoKvDXUdy48Up", + "Bdw5oyadbq2qU5XVAv3VYtUw3L4NRw9qCQ", + "Bk9JHDQ35KX56PiX7rkMnvu3CcvpuTf2D4", + "Bm31pc2gtjUkkQsACcMtAGJQfuwzDspoZX", + "BbBatkUGvY5i7Ap5sEXxaed6nxLxAQ4GzE", + "BgCcFgTTS3e4XZdWvR7cHL2uRzhiJGdqoT", + "Bc21TTeqoQM1VNnwhxUTajJtUoNZs4yZHv", + "Br5iERJvWpiv8AGJxZaSwFFXYmmZhWj8wH", + "BpCJvw9abbxazp3Pw8huSv4zgNwAVqBNfY", + "Ba5MMcuajsEM7yACiacLWi3FLU4kVEy2CJ", + "BioMp3FgjJxkG7s5A2Q1N6kMhTMBoS6DiJ", + "Bfw276uAiPeWbPK3R49fWfRJdUNchTgYVD", + "BiJLwZFRBisEzNy6JFJiqM9YNPemTGHurk", + "BqpHtBn9sP8eApCh7zuHX5g4ZFCS7hBEkq", + "BoviRjYkN7QyMrkj9BaXYweETdwA9xr8fx", + "BYoNPkFBpUWysnjxK1rsnz8w4gkAUkjABY", + "Bcfq4Z3vVK4JwX1cdu6x56CSAuYVctKwYo", + "BdBggtzc2oSR6jBT3fpZBM6X5d5Q5hNRsg", + "Bme5afXNSVsUGC4Ec6gMsKjmSyQnjx5Jn4", + "BecvqyZWaCT42AdVh8rSDLYXcpp4MS527P", + "BcU3BtzyWny4f56SYkSipmm5shGs8CSk3j", + "BcioQsszTThu8FJvpsc95JEyogc5nkAaJv", + "BaQ7qcTwu2F11fE181uT2y6WvNM3rDXrj7", + "BheTahmPuvA2bm5xcdDtSawArpgm6jyz76", + "BjEy2y9UJ6kHn5eX6MSSc1Hh6AKxVB9Uv3", + "BpHWrHTANBwSbkSGjsSUss5BSEBLo5YWMz", + "BaGuLGz1mafC8QXmZAUhMJbj1FkN6pszVi", + "BdRupqVkrXexHztbsyr778uLwLrK9H3APH", + "BnsDGv7PxiWdX3zLbdSUC7CJsxWET35Mmq", + "Brabfr7x2wfowWoe6ZGsdoHp8MGdTjudCC", + "BqnDx65id7s2raSQ1dCoqaXCCP41SFGPh5", + "BhtomJn6H77YSzSTASAk6saZBy1NNQYWxD", + "BfSnRkF1n4eiER6eD7zhqBh8bA3KvdCark", + "BbbtC2gwfEnW8sJdqs1aCnmEMm9B1uZtMz", + "BYEesQS5MvK3ADrhuMuJHdb8ZoenxpeNdm", + "BhwH4TXgrBU9DmqVBAYqeTFgj7ye6Qb1fH", + "BoGcm4xfNmxjzi8yneXEuZ21dXUtMjvEuS", + "Bc3r8FLek5Ao1jErP4GdiqjJ6KcfGuy1UT", + "BcYbbGJaCakR6sX8XGvhyTBYkoJnDEMuzS", + "BXNYZvUVfzgQfmhxrTyanmRbLu6RkAqpQn", + "BVuZFxSKQ9z2gr3BSQ1vRcJAWQT8DfEEpa", + "BoCTdHzzNoSzpVaoQpGSo1SRRBPsWgxHd4", + "Ba2D8sDHF5v9D5qJrNgvzQKdjqYCXdYSBT", + "BVgkwmxWkDSxVE7K2Uv7nj4fjUNgi2GWCk", + "BhpfJHrWpcvaDz4z5jioVHwPHWkgcN1P47", + "BVmJbb9gqZYe3fMj6NZ1cp6LfxHUZARc4z", + "BmbaaEpGpSDzJssHFaL5epARcRfCQtAtXJ", + "Bgtw2dbAnfFAMGDcBqG6M8hpWTokeAtztz", + "BaphLmdqEJC9DcUehYHMgXy2Ng83mZW8fz", + "BYPcyasCpDgztM9e9Wci4T992xgus2LnXG", + "BfK7JxiHqtqnujXKzKdBjj5brfMNqox4jQ", + "BpZCFvYdxjLBEEcapT21rXqVnjeDksEcXU", + "BjXTC2RmXizpBhSNTR3nMUwX1NZNLZkAJA", + "BZUF9UMbGKuUeDxwdSmU98PPTCzevQPCAS", + "BrtZDUM54vQDzUey1kucMzCuouEughegN5", + "BrtkgnLaB71xxLGz9XioAZ8H2JqALhRU2n", + "BUR5snprKpCtj4tpEpnNcmLC3iTW5jF5yb", + "BYn19Sk7V3Tc4bFz24bzgHUD2yvAVyi6QS", + "BjGm8p5RkhBuMEZLHu52bFKw6VL5YF3qnT", + "Bh2CvufXEYFJvGdMfmVx5TfzFSCwxnwHFD", + "BXx6wq3LkXk8L3gCHSxM7Z7QJ8y1UzxVHU", + "BV5myYVtca8mDqD877wnsCvTT9dyysTb6G", + "BowUqjdUNzGab2MLrvRUkoZVypNUTzVHkr", + "Bc9wnDcDhg8odFXwgy7LSY2tPHbyRUu5Fe", + "Bf5s63JpTzkARiRNn6Gtrn8BEDcsJCCrkw", + "BoNFW7eXvKymG5UsAhpvjwEVrE5RgwC1L2", + "BkwBKpXmHx2tPVwn5CkkxA2e8TQuyDWd2w", + "BaKeTrg85yf8mRkkWVvkFNiEDJPB9raKVm", + "BqQqkWJ34MfA5ApfSUSS9LSnLy4TpCpVrH", + "BnroQ7vV7yucLH7TS1zYw5dCQFVpCfsARL", + "BrgKU63pVmcqEpcVba3s5LKHe1RmsaENhq", + "Bo9MobsNcHHukCTTmiG2nMBCHtGd69qhjV", + "BYrcz48rF9w5d2RjyeJyBEtyVjWXNHASNt", + "BeFqc1DQEzyhyRAzYUhqarert8yR3CKzbk", + "BadMPzQXDgGCCCRiLM8Kt8FyxHqb2dgqco", + "BcCyLUjd1tAcEVsQ67TDE1KBqCYFRp6YAE", + "Br89ZqzuU8LQJivmdZeT2gCHbZ8a1WbNSo", + "BjP57w3Ey7Ec2vstSvchH7R4AGf32ccTHx", + "BnWLhbLpMhxk18hJqAMKYjxxJ5Df2pSBee", + "BbewmNNkuenFfckPCbsEsS9gsWUTSmnPUe", + "BXvDh9gzcSrk68mueCMh3sNnEFwEsJDnEs", + "BXDQbgovhnYBxszabsWHb9FMG6ezeYDNHy", + "BbEn7HyhdsbekGAboEGbPLhwYJfcCKsXfH", + "BckmWfdnHXPNT5D7ZjBRQCCLEvyAW239jm", + "BdxawStgggg6vbpoQt8WhadpEQhRaF7TR1", + "BpbAZZFFiGW5HLtpTw9cpPBELn6EKuEA9q", + "BV8KHTKq2SNYqfsksFtqe2b65NHaWcHVgy", + "BakNPbNh9ef3ZDtAWjt7xpKZCpfSBaFyzZ", + "Bd7YMhoPioK25uSfkbHbxhxqbSv8ZS21Nm", + "BoJQqEGpBFNHsLm1nZXk1tWtpFrWudDthH", + "Bocr5amz5rWdp7qnsLrpJNC8NniGxgZ71y", + "BjBZnvrGwbJ1Y3hxMjs8TRj3djbW4ccHfS", + "BZEmPVCj8hziaZq7GESkjS3NNKKxXDgj3B", + "BqyAZpTqZ7Sw3FtYExaGNrTP3ibfcKMKmk", + "BqYi6g4WXmnH6gr13iYGQAUmwAzVcQSPET", + "Ba38vfr7nqn1qjiHMYdkbinKvHZxbAXLds", + "Bg8qZbx21tFjaBKkK6RJHCa622eWbmeYvk", + "BXpeo4zwMRKBzy7tBFKZvh75yRRpMcYqFT", + "BoVGe3TghK1EbWyQ4jrHDeCiuna8Puuxci", + "BZtwLpTJfGSxZoSLDbmuCpmt3ekVdYgiX8", + "Bo4hEBhpUE9qmZWHUvcYNwiBzNSfLy2LHY", + "BbuyxUXxQFfRSfMtx4jV4g5E2VVzGu53xm", + "BbRpVL9RvwdsZiWf1Jm3yDbv5WkZh5iG4f", + "BV3sY2TtZAvDpCBKWAw3CiUa6rhaguodr2", + "BXC87ZSnDRstfpYZAd7KHvgkjoCJUDuBNJ", + "Bmkv7ebZbvbhMwWWKBz7dLMiQpuo74MbiX", + "BeGxciafBvgP5qBXjxmqgFU9GVpnBAg3AK", + "BaW5qSmV5MzheSkWajRSbAbmj63r6cYKx8", + "BhSiH5beuWHUn6SgCbNoaBFiocfHYVS1mB", + "BoakrZsMWPVXtjfBs9Zs7BCRccPFS5NWoD", + "Bgne6igiGghnMrbTYE88DCuZG8AJmhSASM", + "Bk7gDJkpDKTeNeCFsuxqxy3bt5RAnxc76v", + "BXhmLRNRZDChg32L8RsWEhpDKsZ8jy6eYV", + "BVY2QDer6KvVaSeCe2AdiS9ZUkTFUfHt3c", + "BnpAxatKZEcET9rxTX8hopbRg1WY4MLXZh", + "Bbs4hDWdfKUmHMu35gJ4bJcZSayZnc91hp", + "BiN5utVm92MhkjNzLbuSfFgvMQWCr8BYvR", + "BjEt2VBjtvxWoSRYo3KwUwh1eQFP7e2NZo", + "BfhYEzdM856NnCUYt1YdemFFMSEwzT7GvK", + "Ba8qWyuoe7kTsgxRRZw4ZQgjyRCMNBVqR6", + "Bs7TcsHeiue3w2tCmxsGLjLEe39H1n1tZZ", + "BpFt8wZF9FcgWwVm9nDxhH8uvd4GJF82Di", + "BjgKoaVR1mfnFBAKd6oiGkhJhxWtTN8zXe", + "Bi35E8C53mEYSoP6oVMCgN1iFVc8Y5Fet4", + "Bovdf4RA576m8jUAcDBsGW9X16W9wrrfLu", + "Bo2Wva87unvnbU3LQ5Sg9AWo33kQbJkS3d", + "BWdEEE7HfVMVp3YUUfVeiFwj4QSvnaY3VB", + "BgxwnQHm5Nz3dZt7CXe91dAmXYiUzXW4dq", + "BbJrZLMvjkKzVRzUJheK1mEB92Dn58ygMQ", + "BcU6c6dRhiUMp4w97amx5eqiz5xQuQFtcW", + "BZcPm4epew1Z3QEwSArTEZzvr3o5uXz4Hf", + "Bry1cZzCnyPvaNit7cFun1t1p5JMQyTfSy", + "BbDtHrSyhxKQiQYAcPxgev5Xn7YehzNLr7", + "BYSjbM7xJMnz23kuRhPym43GcqG5DqzPpY", + "BcuCvUYzcwR45q8RFtkZ95HdzLqnB3zq3C", + "BnBwKsw8F5Uv43kkzixLdTtoNGTRFUW9ca", + "Ba6ZZUconWyN7mhg72Ewnj6g8ajdGyq99Y", + "BX6GVJArrCBPLY2ApgrGJRAVA761BuUPGy", + "BmDFW3YRcLrPyzTFMQGwffFJ9TYYdYnCMf", + "BnMqyttBDqcHXqUmGCzALvL5fVEmhDohXi", + "BmQEqJ2gSVDhDb6VsrgpbybPt3pCQS3SUv", + "BcuNLaoftXwuE6PhbTsUmLXydnojbpGYun", + "BnZrKFS1DaAXGTuaNFrSDErwWPP5vhynvS", + "BY9vhwggniS4ZDnVp9czZHakFM6MTXn56m", + "BfuK9iy5mY4zDx1TxqRADyA2KNB7vNedEW", + "BhN1GC88uDGUzAtQUpZuL5YTuF1US2kPng", + "Bp5NLapLKm3Rbm8oVeM94H67y7rWGoaJt8", + "BcEwuhyY47rUw25ujocYGTDXoms3VfbpUG", + "BgnEAMFDK12zFMLYThqsD9zJxLSjZnRMVJ", + "BgUsszqvnSmgJQpczsRuzQCgx2QQTAJi26", + "Bcw9n26VPBjWG8m4vLp4m9AqdV7gaaxvFo", + "BkEDwQceMML8Ti2oZU1y8d126mm5bunBdG", + "BWdPR17k1UWBvvAeUWCGzoL847BgwWizyU", + "BpnwLj7fZuXzxa34rDBZVmZ9QZUc3zkQd7", + "BkyvzGn5Dy6ayuejZHDCmBjhsBEXwoS2Ej", + "BpbX2Ay1gczGWZMeYCrQfPwpkUDjn6tbcs", + "BhwpLfHTaa8wNERTFRLvQfv933fKAyTYy4", + "BXcbqgNcdRihTm8ADhnh2ULXix9bZnj1C5", + "Br467A3nrb6T7nNQrD9rpYz5ABJa5S42by", + "BYZ8KDja8Qt29YfqBFd6FniiQbkbSvTp6U", + "BkDXGAmQgyVvUosWeyaG25RiFkabrpWmyh", + "BgQK52d2vY1RUHXYFyJ26juEGbKvNhT27X", + "BihY6kii8Xoc1n8R9afzxiSP76uiBW2nHk", + "BX47fevRntSKrYr543Ac8K83115CQymimg", + "Bhxp7v4QedAU3WQTGMje8YhQDCdVvM2M24", + "BVejCrEK5mXYERdiqNYWP9n9XDEAYHF4Bc", + "Bb1xHZh58jp7SthneKR31orizHfqjj8qrE", + "BgZEM5Zx84M3z9xk5DwvJ8hyxWg6t13syH", + "BagbgKgXscg3x4puBQCGEsnzKyzXYVUqQS", + "BVYoUC6hVTXHsBg8Mz4m9r37ZDJcmN5JtZ", + "BZu69YZ1vHFXQpkmJXF4iwK9CTUzUDJdy6", + "Bc9x4MBPpWhykxracxDd5Rrv3L9VDyiwYc", + "Bj3scPgyMn2fawSscKAQXxSv3PDKZeNR4E", + "Bd72tkvVDEPhVptDAEeBUimwJm9V3CYtqn", + "BpxPzgfvxoLFo8vNupZ8vHPvzXJgHWv4gN", + "BedW8fTJxNgzgV5aThWN7irJFstBRo5bf5", + "BqEiD51uzPTRGV29rYQBS6GHLvjBGdJZAH", + "BmfnEDAotScUR8uRiRAqSfepgYqkNA2ep9", + "BkjD6qSiPtvqZY6rggyjAkwaAPK69Msu4e", + "BiUFYVNwFSCuoWiW3AS8yL2hR7uMy6zqPE", + "BXAnbGFU4LJr2HPQGFtirxC8hmw5xrRZFi", + "BUbZCJbi8FYcVaDtvakhFppaNMTRsV4a5Z", + "BixupaN5uh7AF4kt7ppdJ5htoLZjgRLCpr", + "BcLFPyMiyURY9SwM9Gmka8RH5GLrY8TT2Q", + "BbvEysNvdvBCJQG1tN6e8fT7LKYuFFWrfe", + "BUyMJYsZRFDkVvqjYZuHfN47kEZwU6ooNw", + "BqYW3tmmJAd2EobqiCWmqrn6Rk9QWmbXmR", + "BXHTdaHWxkMemPwHVyCLFhDzxinkdrnvNH", + "BkxekxuqkjRSVC3egVTkhbwKBfkTzEmhFR", + "BjcFn2zNBdThGjqPS8ktj91fYWv1JSSrgj", + "Bk553e7EJUQ6V6ktGfGn144XBcf1x877Gt", + "BeCzWjtWuSEy2399LwjAeyqHox6sXyxWrc", + "BVoF8xLbuDVxChdcFbf516VoA9Eu3kQuKH", + "BoAWnfcUiEYd2Js3fcyJCd7bGDBtAjaXBt", + "BZ1gQyaQ3fnUNTyzTZicMHJXQzPuMhHqnp", + "BYJjewMbf3EswMuka423Ad7pdhZYCg3Ndt", + "Bchvuxpq4ebswtthZHS2anmbQ9KwpL5NLg", + "Baumw3pq5Xx1NYBytYhDuPE2UKvDqSJRP9", + "BdFAgsN2o2YdswifV4HyV47mkiZuok9A1E", + "BoSESFsXpGazRx5GmWanYeLpRtqmDjXuAH", + "Bim5rjmQoYRGmFHTTEXqWNEemhsME5JoJg", + "BmZxB4DVC9wDg1TAWmU2GoWZBqxhn8cPrk", + "Bj1qPSytn79hKWUhu2Qe7ePnWrdcC8f7aP", + "BXvXTPUtSVo7CKuLiaFuMEgdjNAUnFcfKs", + "Bi2NztamupBSNVYLxF74iRbNJcAXtppzRK", + "BnY6Mxjqrh5zSa6Nko6A7wi1HqC9UwiejU", + "BpobhHosUrmAjQCcMLeDnw8GWh3hr3TB1a", + "BUxNZGC6eSCYftmW2JLxcpARqpc6GxTs5g", + "Bo7Qf9oyDf9hDaEnY1efXgWf2p7hq1pDDN", + "BchPvrQvk65phF3rEKtxtdvzn4SJeMYpKs", + "BXfC1A5hATTQPTS1FBumgCbWU24GK6tohe", + "Bbs4R2JGESriQsqorGSeiqamGYxSnQt3c6", + "BfCZBiXFN5WkeAUN611QhycunALxysn75D", + "BgFVKnsdBmUysPPdDoWdamNoNkSLBM4w4o", + "BdXPCQWV8gkprXWA5py9vYC5CYtk2FCGnk", + "Bn83v2gdMRWUYraJ24cD2DXq9htejXTD7p", + "Bi7GRBjApuyq7HKcefEt5fTAqiaw72cbzq", + "Bmiw9SsEgVVgn5KUx7TQ9y8hvm1AbkCdV4", + "BeYkJymZp1ThnvSRMWbgmNcPs2XYFWmbh6", + "Bf9Drjau1QZbyho9UsKxzE5nFoiHUwXu7H", + "BYxMnwcbCCbMYGkvLx51ZYESXfDCJa76cJ", + "BmVTmUjghvoKCx8vaQ9Wu63JL76dxDxYQF", + "Bdh62em6UGW2hkZJvqG6tqGkYe6D9uiU5R", + "BgsRCEr8s8fCR5y7FoUHJtLrSyK4PZzjRF", + "BmJmy5bdXyn2DVbY85hdAq9MNr9emjTQDa", + "Badb8G8W9jpS4rX8pnuP6APp9UTtMLYxf2", + "Bbueq2hvf4n5GEBFMv2ySZmXyvWC4zJX8b", + "BTuad7BAvcgnovGoLUSXa7SQMZp8GfX6BG", + "Bioi18U413xVXjPCB3fq86rp4cAFsCU8Gc", + "BiWFhD5pDvEEjNJzHToGwQM8Nu622TEdN3", + "BaAZo5x8o4TxJkNmtMyV4VpXDwFGY52G67", + "BZAzbGKb1wbNEurCk1Zk1DnGgYcMo49Mcc", + "BYk1nwKUj1fZQWdsfk2qM53Ttxm6nr7eE3", + "BWqUEoE9zk1zUvtLrxhaWKhcQ8NoEtdxkM", + "BqbV2RUjT4zLvfDaLjDipAsW3dsXoibhJe", + "BrRxcooEZ25AzPVufVhUPkphSdxBei4JxT", + "BpL9dLt2bZNxmoDLQkk8pSVehZt85T8Zcv", + "BcGbB8cx1Epg4NY4Y4kNXiUCZMq8A6P7k3", + "BVrCxyPV32XXkpcQuHYtptM1ZmJRzJXFAg", + "BqB9qYtz8BYGzPk7gB8rjeyfxt2k98qgMN", + "BnRGAEZo8Jqe1eT2mfFx8wkpVL86Ni8RN3", + "BokoiDD72bMVinrhs6XmKF5U2NVZfjkKxv", + "BrcPiK2gYUgXKcfQmLFWCMEsPxrqtDyehr", + "Bqo6qKktE3SwcfmKvxnhyeu7usxnY1wK4y", + "BdYdz2xmVb2cW9TJZ2pEqyz7seyiX2kBnY", + "BgudyZnoJJELR3ths46mTU1KVdSQX27aod", + "Bon3U3iLb9P3Eh7BVV42ss8ibqDW6Hrc1B", + "BZPMZuNDhHCy5K8ZvjkS85HjGyABMrkwNG", + "BnE5qswCcf2cysxMkjoL1vzqEXjdnvBjms", + "Bn8ZcFaweAMnkP4tzpLLRTGCeSsDzTKUtq", + "BpiFPHHZx7DeqYiunwCD1EFiDgSzAdaWLF", + "BiVgNiCRWcngZeVqvEtT6JGQNAaL3bNijy", + "BWqxMGB2f3uGmw4D7bdfTgJ2pzR2DkNGvE", + "BYtAWvxogyo8gp3LEUMm43ZaJEfJmfgQfT", + "BgkiV2Wau5G7Svau1dzP6UJrKSiDYQFMca", + "BcpjNNP5xo46FPqc5vC2CVQYV5ZaKHnfvX", + "BZ8EU8wSvLtN8r5s7ekdBSUREYA3MDqx9n", + "Bfo1uFVzKVUDfu3nQa9VEkt23LcsD3EJzn", + "BjG6mddsj7gUxW6agEiPAta1dWNu2KdWnF", + "BUijEUFcfn5xMW6m3M6mqf4y8672ycCXeR", + "BbhgEC42ActL7NQqTYwfnVBMjUaoC9ave1", + "BiJDC1CV4Aqy2wQifHue5hFRLeHP7XESfg", + "BckjoUBZjUW6b1oxh2AL34CQdUfHL1T31W", + "Bikm4Js4xpNA9ojmq92SW9upSEyELrSrSr", + "BpJCHR1hnFmAGpRWXjsjoY86dxjnFUzGyj", + "BZWMPGhAUmtDp22HHppgntVt9Zjoa4dTwi", + "BYW3CnJSMftFap64QYUu1CQMKvPAbfwDMT", + "BahBb4eJieyrrRvuYZDEQKXz2tMnMfBT7R", + "BVXo9zww8qfeBVM5bhr5Duq5EoL1wxV74H", + "BkUpKkH1uYndATa9CBPuTAitPM97Qrv9iQ", + "BZB9uBHmcKQXLjHXxnAkZAdRMAgPZcxGWG", + "BcEsoYjmubPJx9hufdAwaAwKLKmtTK8FAt", + "BXKCXRbR2gPRaTQjTDUgiAGjf5QiUxE7ra", + "BTqaHUVoMS4dUGLijPLJQ4seefnhrzKG4H", + "BnUqzim9GcqzzUvTDovWmPM94sHKy4zKCd", + "BTs9aewNsCtdQPwL7N9jVjer9ucrf4nbre", + "BinxgqCHLstNLNNkbQ4MkMVyCcPpmwBnyf", + "BnACoRcbE7puTe1NFctGxbgT6YM5UBKpWE", + "BiQxoMNJjpD811Vv4CB9EMEexPnhSbhfTY", + "BonHbLH7gF4ppy8Eqht6V7rosaRmgud7RK", + "Bm3xS3YTScz5h1z9w7ooodAfNQdtTfzcVJ", + "BWZmHx75PtWyEtCbfoKgnjYBBM1hoAy6By", + "Bji6EbHH2gGrnSbsgWUokyu3XHYT3hqX5G", + "Bf9PQVXmEa4hsQb2PDdWoifhqbrtz2oUdn", + "BgQUPq8AX67T8qaAPt8UmGdJ17cEfD7tvB", + "BrGyygA3JsRyGSNkfLzcKNMAc8DfCXxraa", + "BY1BK57HG6TUyaXijLrT9syjruE1i9s2Sc", + "BqBFhCzsoU2mFBcs6XVNdYHYMuEUjCY4Y3", + "BXjYbuWazak23rmmK8HCD8PRmTNQsDhQUu", + "BXWsFxVwpKnAo2NEiM1NSusM1NLqQ2HoUN", + "BYso6DgyzJK2MZZNCoXiig791hcGf9Zd2V", + "BYBXR6WwQHEirPA8tjq8MmeSYDhBJJiTEV", + "BjY5wahUhX1cQciM5PdbzwCfj1AjpQ9jhA", + "Brxin8Y8UaaqFACVQRuXxyif8B7jF5j98v", + "BXWtobez2J5c2he4cu1vnUYVhdjANitwpy", + "Bd6uuGheuPBLN3C29R5SQNENbT6uVJ3ZQ1", + "BWAi6eKY1zKAduL9ENThqNdDre3w1K9KFc", + "Br3CCjZTBMF74vXUoNrcxWmwbU9YjTJqqP", + "Baim5KCRwikHWeEm6HScgN76uDVvAmarFY", + "BhAFJ4Jxi2Wk4hCqSnP4TLYdsX6DZwPuun", + "BUeCQo2uEVjtxkgUAmMoj4mUkayADYqP2b", + "BaxjkwehmvPiap6oUfvsyFfisLQR3d66X3", + "BVr6B98g8uQkyvwRaXSSGwnBYEyM8oqghT", + "BZoHAs3DB3NYn2Dg9DdFm7ZUoNdr6GH74B", + "BofTk1UcuGvqULuwHK2PK2TwC3yJWnfmoX", + "Bpq3cxqP4P5zUF8MMCGkuLnyPmQGo5Pfjx", + "BbXxvJUV9Sd3WkS6pe4jWivT4weC365N2K", + "BXvfXRa1y8bp3Vxu18v6pGBz7nirYGhQtA", + "Bcjj8UZBAsYDSFmq9rEtRmrRrWKncqwTYB", + "BVGNasnU1U7B7czAetSdUJtWH6KsB47g3L", + "Bb6Caw186eff9ofjQzouRfMfNb9qEraUGM", + "Bo31hkyfnfuBdtT9FEnxg4ExA8egkkEYa6", + "BUKfRYg14gNy3Zx4Td9vt6izRoPVFFj6BX", + "BeM9ooMDg6dEZpQPmRbDtXatzjuAiwyp6g", + "BW85YvfjRvNJ8UHgkGkzg5oeHNwSaLMaBj", + "BaeVzbMGEcuQqFdCuPL9hQ5vJznncpX8pv", + "Bj5PThkzkDWqyMjtCadNF7ronMsYhvt8Lx", + "Bn4u4V39SvvdgLrf4uk9pLoHoY65vM1cHD", + "BVDfgyS8CBgpcNuJRj2zbYLe5cnxCPiQfM", + "BnSM1Mb4wjJQURYZKbWoiuvPDXLHkAqjGJ", + "Bm3UamKwZ4SN2Cs1cJBhx5D8TBNr9EEL8M", + "BeURSK5rhe6QQdZG5BKQuHByXesRPGe3QX", + "BkZLqtBsNv6ESknHyKwDeMgt6oXUu3qLmm", + "Br2F9GWJdZcrbcbojHrMXwWcG9Rrbc77gJ", + "Bjyn7SHfxaAbReR8YCXcR8bHF6n8LfeQNs", + "BgvrqU79q483Nh8sgJfMC1NZLRna8FkP1z", + "BYBtft1vP5MwUSfuKtEyu2G27r845mAeFh", + "BV77K8P5eNNccArbMhu8epWNzGzF9bGCgD", + "BZyQxAzseBrts4NdWencYbK6s6XMyRfrYy", + "BXBEoCaasQhDd6pE7AJUt8LbmZKBtA9ShD", + "Bp8i2aUkr212co76YTQ63iPwBJAUiXxi7B", + "BgfKLvjhmCcGEmCBV24rqDh5zui8RQ1rc8", + "BWPinPEiLPS8uXwnQynA2xGMcXyv6ESJcG", + "BnwxndjBbqch9kRAz9sfJYbknMKMyYP8JM", + "BaomFMx5w88iXvDduPtNijBxu8SfJJFL7v", + "BdwJfew3GgRkcQudDaVv6HN8s1Wtc4aKPU", + "BnsLhTWVUSeXVWVZPUjtbCkcxixJqhrRnM", + "Bfg4Z4d45srmpqhud7XUn4abyxB4KirA5s", + "BjqZyHfahcnScJt2xx9awJfnS7UD8J2Yqz", + "BUan5rCvvcp33fMrUm1S4UgHEicXVQboHr", + "BfBiUhP6Zmi93dD5Q7Uva1G4QXEkDwYpic", + "BURsZaUKqgHzvFUPywupyCyU77Hq6mgEZW", + "Bej9ReXmVuG5CA3JQ39ffV7eSkcNMwDUtG", + "Bb39eXZLMGbScRqZYNpBXeqRitPWEXDHks", + "Bnpb4V6dSdkMBzAUeL9nG7zQ5iQfX57ifA", + "BWGKFXLd1cFuvq7cLrE2rjhxP1MwPd1Hh2", + "BrUAQT1s2nUat3YE6GhfCT5WVK6QEgKq27", + "BkxtX5jrP6oUbxG29JzXWDwGNVwpcZBwPi", + "BfxezCqTiG1ocxuLYHZjpuXk6es2mg3TqS", + "BYfDX35NGZLztNF5bTT2nUJqDAT8tNAvah", + "Bc6rq7e1qNdUFQHza3bWdt7FJARvNy6zu6", + "BZn9UtgmqnzmWaVA9xvBTVsdxoQ6FS1qEi", + "BqqMBT6x1BJ7DzRSCZRc47bffjtNACqzLT", + "Bkv5qzRGPPZfaT8DbsDeU74dDxrUikoXQ7", + "BYvjH58aDv8JPRCHnB5AzHTxGvoYJJPYKF", + "BXBL49Cxf6orMMX6JDYXkcygUM88cYY9Hd", + "BWV2ubjpjUWEmjdhJSGbtAGRtf5r7N75Kj", + "BaKWzHDhoMnDyQFrCQakV9sntZBqpVWiSV", + "BbYFYB5Dw86yRQxji6fVDC9MMduzJmTTT9", + "BVqzdVuk3cg9jLL5YJJ1Aga7ERLZAtJCEz", + "BXNMnA6CGk9Ew3gMzd47GXtN3vFu1FB3xh", + "BqsFnvGd7WCD6Smvxv9rhEy3EXgpVDjXac", + "BYc15EwAAczXaEDNNbEkhYYsAkxwVki6EA", + "BkQYoga8kcHR6skyjqF2MKfvQC3qr6MQDE", + "BnuSpap7uaHo4KF5jwtKUbDLiqcrpzVJCG", + "BZDvZLAg5WBRVSLqEVuF3g4H6BCx4KzF8n", + "BeoAR7xbr1dN4anqGbxSjHder8E2Y34XVR", + "BXCkq457cXtcXS8xjxZQ3MZw9qBBpqo4L9", + "BXdTNj8X13BBSJZxq8AuiwHjhPjCkL4TeM", + "BivkqQQ2juk539vPqqyAwJAAdphkS2Qh7U", + "BhpDvMDe47EnGqhbRKovULvAx7Ei2Kof4p", + "BimG5M79W1jEGNk4Hq2auQAiFC6TrHKqCf", + "BptK81p6ka33dR5rpFCG3oFLDmcAaJwzP9", + "BiY4PDhobHN7TAsUqdD4VMbwQ6UfgAcvd6", + "Bcs5wrRctjs12LyamXkoPbwaaGkBjDe1sf", + "BjsNDouoZyi4b6GWKdwvQaY6cqLs9VmzFN", + "BmNj2XTJmjmHKEpVAJDTgJmrz6CwDqcRoH", + "BbaX2sviHi5CtNLAtp472pnbt2aWNSXqUe", + "BaMdg4QtKFzCwq4fCgghgPfiYXn6bx1FNV", + "BfnPkwf7ScFJDY5Ne2PCfjov2EKqgRfCQQ", + "Bgw3n318TQobJbwqb1kcUBAUCqnmictiQy", + "BbgfnfAVsBHufsR4hEJbMGZNvoseG2hyeB", + "BZe7679jVse6nR7Z99CouaNfPvkGCbpiTJ", + "BZgfHGNmkP5A5vsdaZSx4UPECNGG9SjbEM", + "BZyGS8GrKbCFHZG9kJ5RtSMbYTeb83xCuQ", + "BYJBtX1a9zFY9o4tUAEvA2Nz9w3xbV9Gsm", + "BmGFEP6do4gAQBjVYftv4KzyzakxiChCX5", + "BZcBfSC71S9YccqH84yhCwmJESjkyHD4RS", + "BofV62pxDzL7twMcSUA2i6T3ZcFZG4myea", + "BqcRh7GKTeJoipo72rERcMxGvY4MbjvzZv", + "BqaMV6ebHLG18TWtDVQJWeCybvnRrj4o6n", + "Bmh13Ai2NujEsKnRtQZ1FZ5RYuT8KNmeCz", + "Bd8Wi4TDLvtfefK7RKSTk3YeSwJZ5gpAFC", + "BhUeeyhPkjLCdXL3tScB5R5TVrDNknPBzj", + "BiykuJQiSZHma8n3CW18tRXztFxaUYmQLW", + "BiSbsXXZpwXLtwunzfCF6dK9CcT6Xm5cSC", + "Bk45GmXRHg4iRM92C7BYwxd8K6oC1yDBZh", + "Brf92mSyKSEMpGRdyBSphn8GtV9dio4Xam", + "Bo85F5awQacAV5ikbZvYY3HN2hFFkgyyty", + "Bhmfsc7VpbKAqwDo1uEujTRrDxntLeJ2L9", + "BpVkjNoMv3fskuhkjVJ6GfYkUZDC8jUgRj", + "Bq81WjojFkpJjdmtMHuuzoCkFiXk4SiKDR", + "BbhvmJTvakSTVu7FRzfgVnRqwvUYikRjhX", + "BqjYXY2GTogtp7MoSR1zoXBrU7S8Y5Xeor", + "BpEC7C6R4fjiKeVgKe8yk3pVsXE6A9UUaz", + "BqqKrXhNS8RnSEqN2KtdxqqLTyx56C9Boc", + "BimuwMqvQgUmovChm5HTvgrF4dZkXmNr3x", + "BakNqavRYqaJwN6fop3QVrXXLCmSd69CK3", + "Bijb71x9vCGF1tmS8NxTExF1YsM9Wr42qK", + "BjQATPCk8jHZspYtjnpowpxzwFnpLdL3b7", + "BY3qTKKq56EevmiQVbUWERJrAReiRrciwY", + "BkSuLfpnyU247e7XYfpBSf1MbVfmmMVfmj", + "Bo1yy6K3kHwwpivsRcJNKrSDfcowtGx2i5", + "BjNsuRV67mXJstFeZ4AgUdsZ7N7qe8nADP", + "BZrz2jiK934k82AWYBtq4pFXWf7zXzpa8L", + "BfxMrzjh2mj8EwqRBUhX6ZqK6QbZoRMC3f", + "BdcxXBbHbMyDsFjUUKy9EdZiAJn2evS2Jp", + "BW29A72EyoCnGmMoSLkTxTbshqY37yJDoF", + "BrNVjtbmGVg4crAsDvCxYRL9dq6ogtyJB5", + "Bdfj6bR4Sxu5ULpQk39TG41kGjaMH2VZqZ", + "Bhg354Ch6roMDuNBaEHDKLzWh5Q9nE4Zyc", + "BULUXLRAgefU3JoNGz1S9UPAJQ6VnuJE8r", + "BmEBGiBZYsid7hGvM7aVxWWSQZ8ShyqFrj", + "Be4P3rLMLdyVZqMB2CnE5TFbXvBgi4w7yx", + "BnFQjrmAZbKFBhdrywF44jr65PRGTFTV5M", + "BjbCzdbxfxAQ69Dk43un2Ui2ah58uKsZVz", + "BmiiWSVYg58Py5TtzACkedSjZzKFJqA3f6", + "BVhuT3acA3cFVFKv1J4ijtXbph5cxVrkhQ", + "BVzN2r6K4KGFYQ7XhYb2cEchpJV1Tn1dVG", + "BnGGxsKzfhbEsF7LfPCiBbynLPBPAySbjD", + "Betw4vHTSNpFc1LXndCf2upAKy9pMVK57D", + "BgujzLrBCDWMm4AoERyqjGLDQsyRUW5eTW", + "BUJwqeHPbdDkbNhdNHermiyPYnJaH7kZv7", + "BbH8SrNdN68VFHWcG7vrvJjUJ43xrUmTaq", + "BppMruCPkQi9tFg1w1ry4Gypa9eSJxfoNh", + "BqLHzQheRN4f3p2ojrm6CmskoZidhAtjAE", + "BaWDwaHUA8WRgXqQi2Qrist7LWvLb6sw9L", + "Bc4sg1WRRmjYamLviv7yGW3ERroD2zuwwj", + "BqrGrw88SgSSDsLox6MEwTtZA9vD4VTmdL", + "BrK7LKL9msnBip5A6z854QHWu1C72WMQNm", + "Bgxu1NLWFbZsUzBYb3GpSwy6SErbJbyiMk", + "Bb5cYibd3G88j764DKVLAMHaqHkT6H4MFk", + "BcEL13pB9yPhqbTiCGAujMLnaLFJzEwnKL", + "BmSbe1dfua25UBY6LyEQ6UkmFnyv57afJb", + "BfQpkbqrpdUvn8hopJ6NZTtjadjaMhbfEp", + "BoXhdrpX4eBWniG7MjkkrAyHVbeAksGxc2", + "BYAAe1MqixybFu6X6egyZx9TjEh1dnEsz9", + "BksCqSam91mR5PUproA16ac7VUxK6nQToB", + "BUgbvhvBBJPFZWLBztgrzu5rbXqNPDEHLc", + "BfwjkA6rnTRt4fZswW9HxbyVTwkdYqDCCu", + "BaQof7W6h3y7p6sGZp9kdm13TSezRRWJYu", + "Bb8TXEsaEvrpg94QusKtw7J5pcw1yC5HNx", + "BeLzkEDv7TeFWnjiJ2ecB8iPodi6VvQH24", + "BpLUGjPKZVh3ZRaLZkDc1PVwxyKE5yizkH", + "BcCyf3EHuFxmDgfah9bq7BFwfSv6nFpbtP", + "BnBDSQry4aisR517mXDBz4nrEiqPN4rU3K", + "BhfuiiMdUXRKY3221R5w9xCyS4YxTwaaRC", + "BrKKDsVHbKmr5tBrhaztmA8phBdaZZ3wSw", + "BriWNFTvvpSCN9fYbKFqi1qb2kKDW8rgtT", + "BVW3g6fyjuSrX4BZWekkS3rLz1JfHbWc9K", + "BgtxdtWYqv1xCwSYtXb1dcgxxwB2c7HSSL", + "BXpueT5nU9ky5MfmsiRnL3WAFpMnAxsyfr", + "BraBxBvMcQscm4qjDHTbzjDEAdMBibTpDt", + "BkEmL56DZmZmppzhVPSHSQapCAhvgTHXd1", + "Ba7DP3Rx6fWqvuUwS38c8Gd34NFzfTejiT", + "BnUp2C4nSZR16diz4jQGHoJgmt1TM1JfrP", + "BYcR3SKskRWM8fbAptRBCAUHn1GRDGqERb", + "BfiCcQpFqSE8n8usFJbpfZhHcjj44v3KnY", + "Bb2TLKDUh3mKmd7CNSn1V9MZEs9P2f97ck", + "Bj5uRztmrst7fuaQsfPz6tb79ZeuXLkRie", + "Brao1vccnJ6RQpYYq3AEgdjSUhc9SW9x4e", + "BhbVrtMbBb7BwMV4LgVuhsk41NZue9kjNk", + "BX5BXmVhJkgBpjHTE62rTUBxEHQeX7dTmq", + "BZvH4Zb8aoupqE7DaQFeDzzWs4BUXQx2tW", + "BhkCtkxV9EejmB1yxzt5fdSNUnp66DMcUn", + "BUAzh28MZtatvMr4dvUZCDKh8s33FUafG4", + "BpwcoKZQAwqB8FRSMV1rSbYJJZorvzkRsn", + "BaT4N1dxW9tWdsMe4yewd8pPxdi8i3csuQ", + "Bjc8UEJ8FiC2WABEA3UbipeAR2fJiHrbt4", + "BdzQjioCaXDUGq5CEkcYYS8DDqpa7yxjTL", + "BpovTnSV93D8gXvkUeZvJweTtudHbJw3iH", + "BiYJqBvAL3EnRcMzPG4H6xrsMsGoHkvKyo", + "BfQ1fLG5Jhz2XiD71JpABDwQntNYkxkspc", + "Bacxjfj9MPXj5u419oUwF1MCjm9cK7SDaj", + "Bnz7KAWH5d3ZmN9z8DWBPMrnjFZ2nzk4yQ", + "Bo4KjQKaWMnb86YX3Jiy1PjUpu7ERAVoMY", + "BcoEEEexWYwTW6Jow1C1JB1BZrx7qRfPjp", + "BW9bVHTfzfpNyz85AcnN1pzNka8JRLcjcY", + "BrofsCPfUVfummkpvQgvNH39osogyy7JrE", + "Bnq98QvukTKB4MQPiZoRo3EqUTJNjGPRi9", + "BpPkEAz3pbu4JpSSfkueijkfWe7XX8PXFb", + "BrGzTGEQa9WCotS4geKsS2Gjt66VYzSMvN", + "BrA4bfQPxHXt1ZH4BGzhVHzgANj9zR1nJC", + "Bq9PxrEvo1bPHwEej41UuedYzzV7d7qsVX", + "BfFndcBQhWAeR18sS1ahfwwvwuJtxLfJuu", + "BrL77xgWtTjfAH7nhLw9Lw7hs6CCXwUHSA", + "BiBvC7gDvyTeQysAptHfHJJWB1LMEnHuJg", + "BhQiAHtrQVp186zryfSCjmF14i5VMyp6n3", + "BYomeKtJspVtS5cHm47NGEeXaq6YcrJqPA", + "BczZhLNcwhecWkX56S1tCixWn1S55UKdiX", + "BhV96V7sB6Qspswp5sJaeTwyY8Zhrc4w5d", + "BZoJMQmK1TBSsaPCz4ibi5AaL4rTF7ekJC", + "BnJq8fWHN8MgFqrsPKNgiujJhb6ayVW7vC", + "BkWrixUYFB2LQ1HsTLWExDkfQUfYhvPA8v", + "BkgBWviGJYx1PLVNSUBWBmGtVu9Uvgo1y7", + "BZET2U5MfCJD2ZqTtpNBpfxzinZD42bUFc", + "Bhui7woKrxCDMdhTz1j5fZerHdzj6fSF86", + "BfM3qHV4gvNppSHaq7U638pMqskvG7awDj", + "BTrWbw3egYvN2XLPnuoeRQCb4APbjKC712", + "BUDgkzWxJyRuLutRiqvmWHRBgUAdFtjqiu", + "BpvJNnRmtMW5Z56EjwpZwEmG8my19Pjw3d", + "BVyhwYcXtQ6vMAS1D4JnDXDFDKRZX8RbLg", + "BWoLHkqk9xbUV6FRHwrj6HcBkpna1aVnTL", + "BUfmcRACj8di2tRSg7BCVeiftZMfKi88yd", + "BpZfFPNRFmsxywPRB1y9wZvHKNZ5SUwYzh", + "BZEdZSQodvkwvQDDnhA6FNUnM7a1fc8fG6", + "BfjgJtxxTwLUFMEwbQuRGkVufg3iJ2H5JG", + "BkFoE2PZcNQUxYHYG9Mdcdq2qeswSNHeUG", + "BUhBBeqRPFA4T9fmgoZynvb6267KJfcp7d", + "Bj2cg6NTtPjTE9QzBfh4cVd9gmAFYeTeFD", + "BrGJdznxeE7t18jXJLA3aswYzCBuZiuUEf", + "BYmV8ciCUAbY191YtzDAhAUNjbLPd5Vgha", + "Brv9J7HqHv6wdwjQ18T3dCPzczgieBBfU4", + "BbqxS59KQV1mbHY8CuGFyJAbc3FzUp5BXb", + "BWd25obzLni8U5wfRiw2gsVFV22PVBnFDP", + "BqFL3qqd9BdqrNfH93oV9BJv8xRGXJpK1f", + "BjGjjG2HxWE8HLoLahsHpzUtuFejZfNdHy", + "BbNuXsGcsSuaHqhVr1hE6y3HsCTykLx5RW", + "BmkYkrjtaLU9N4rcnmyoS5WPyekM4ogZML", + "BTvn3Eo4GgK4Ez7WRUkpGmeM4ZapV3K7UH", + "Bdsf1MzYjQ8CdpAETY2A5cDUU2Xe18GHcb", + "BfE3DBdRfBdYa1KAHoZCDinXnuSNz37avx", + "BVsxNcYutZZiDp8ZHfYujCJMMQmeFmCCpg", + "BeSggYEFaxqDfBnjDXk6rBJhpziuMmG7tE", + "BfuVuYMyPBESNota9wLkqqk6PdZ6SYcnrN", + "BY2b1iZA3NjpsjWAi3hzZNCqsZA8M8ac1n", + "BVZAyMsZqnEuFxNpmWQLjxLYmGEStwMqN8", + "Bp92khnxoKu79RjemrGY8NfLBhtierwY96", + "BmzagvpPSs96ppTS47k3BaxroiaJzT1XgQ", + "Bcudp47shovmshNdHE4YN4NEuKPYtUzKpR", + "BnNC13erHJvmafrcM6prdBix5NnmxfbLqj", + "Bp3kYzeATnqEMj7MpU1UFNcXyxXBihBVwQ", + "BgGHGC4pA7sx9iJju3cq3MjyWJk2wLGLsD", + "BVkwkSnJFKXFCQaBsckv6boSpugNkKH8vd", + "Bs1BghB2cSrX6iwzuzUsGKmBVusApmcukJ", + "BpzaYcdBv57he46oeknCzsdc1e7bA8wSe3", + "BYH7ydudGEob49ooj5o9F6jgrsm7zoUR7m", + "BdACDcDK7uzfnkHbvcUeWATrRM7izAfKZm", + "BeFEH5dbm5tUCCLSHrN1MFXEzjjr3v7VzT", + "BePxRjQdCueJiGgFjKXmXqXw5posmWrn4E", + "BgVt4JBwZMC65bbEwJ6JLSBXxCmVxknbon", + "BrKTbohYs39KeBobEdaEu2CncXumr4ddSu", + "Bixxv4tqhmwDv6imH2zJg89N6oPinoDyZT" + ] +} \ No newline at end of file