diff --git a/.gitignore b/.gitignore index e3e727c..26bffb1 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,7 @@ nul # ---------- OpenCode ---------- .opencode/ + +# ----------- AI ------------ +.claude/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a057aa8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,247 @@ +# IbisWallet — Claude Development Guide + +A self-custody Bitcoin wallet for Android built with Kotlin, Jetpack Compose, and the Bitcoin Development Kit (BDK). + +--- + +## Purpose Of This File + +This file is just a notebook to help guide Claude Code, if you use it. You can modify this file in any way you like to help guide Claude during coding sessions. + + +## Toolchain Standards + +### JDK — Eclipse Temurin 21 (LTS) + +This project standardizes on **Eclipse Temurin 21 (LTS)**. Gradle 8.x supports a maximum of JDK 21; newer JDKs (22+) will cause daemon startup failures. + +**Required installation path (macOS):** +``` +/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home +``` + +Install via Homebrew: +```bash +brew install --cask temurin@21 +``` + +The JDK is pinned in two places so both the CLI and Android Studio use it consistently: + +- **`gradle.properties`** — `org.gradle.java.home=...` (forces the Gradle daemon) +- **`~/.zshrc`** — `export JAVA_HOME=...` (CLI tools and shell commands) + +Never change `compileOptions` or `kotlinOptions.jvmTarget` to a value other than `21` without also updating both locations above. + +### Gradle — Version 8 + +The wrapper is pinned to **Gradle 8.13**. Do not upgrade beyond 8.x without verifying JDK compatibility. + +``` +gradle/wrapper/gradle-wrapper.properties → distributionUrl=.../gradle-8.13-bin.zip +``` + +**Key Gradle rules:** +- Always use the wrapper (`./gradlew`), never a system-installed Gradle. +- Use the **Kotlin DSL** (`build.gradle.kts`, `settings.gradle.kts`) — not Groovy. +- All dependency versions live in `gradle/libs.versions.toml` (Version Catalog). Add new dependencies there first, then reference via `libs.*` aliases. +- Do not use `implementation("group:artifact:version")` string literals directly — always go through the catalog. + +--- + +## Project Structure + +``` +app/ + src/ + main/java/github/aeonbtc/ibiswallet/ + data/local/ # Room/SQLite caches (ElectrumCache) + tor/ # CachingElectrumProxy — 3-socket Electrum bridge + ui/ # Jetpack Compose screens and ViewModels + util/ # Pure Kotlin utilities (ElectrumSeedUtil, QrFormatParser, UrAccountParser) + test/java/ # JVM unit tests (Kotest + MockK) +gradle/ + libs.versions.toml # Single source of truth for all dependency versions + wrapper/ # Gradle wrapper — always use ./gradlew +``` + +--- + +## Build Commands + +```bash +# Compile and run all unit tests +./gradlew testDebugUnitTest + +# Run a single test class +./gradlew testDebugUnitTest --tests "github.aeonbtc.ibiswallet.tor.CachingElectrumProxyTest" + +# Generate JaCoCo HTML coverage report +./gradlew jacocoUnitTestReport +# Report: app/build/reports/jacoco/jacocoUnitTestReport/html/index.html + +# Build debug APK +./gradlew assembleDebug + +# Clean build +./gradlew clean assembleDebug +``` + +--- + +## Testing Framework + +### Stack + +| Library | Version | Purpose | +|---|---|---| +| **Kotest** | 5.9.1 | Test framework — use `FunSpec` style | +| **MockK** | 1.13.13 | Kotlin-native mocking | +| **kotlinx-coroutines-test** | 1.9.0 | Coroutine test utilities | +| **org.json:json** | 20231013 | Real JSON for unit tests (Android stubs throw) | + +### Unit Test Rules + +**Test style — always use `FunSpec`:** +```kotlin +class MyTest : FunSpec({ + context("feature group") { + test("specific behavior") { + // arrange / act / assert + } + } +}) +``` + +**JUnit Platform** is required for Kotest. This is already configured in `app/build.gradle.kts`: +```kotlin +testOptions { + unitTests.all { it.useJUnitPlatform() } +} +``` + +**`android.testOptions.unitTests.returnDefaultValues=true`** is set in `gradle.properties`. This suppresses most Android framework stubs from throwing, but it does NOT cover: +- `android.util.Log` — always throws even with `returnDefaultValues`. Must be mocked explicitly with `mockkStatic`. +- `org.json.JSONObject` / `JSONArray` methods — always throw. Solved by adding `testImplementation("org.json:json:20231013")` so the real implementation is on the test classpath. + +**Standard `beforeSpec` block for any test class that touches Android framework code:** +```kotlin +beforeSpec { + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.e(any(), any()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 +} + +afterEach { + clearAllMocks(answers = false) // keep stubs, clear recorded calls +} +``` + +**Test file location:** +``` +app/src/test/java// +``` +For example, tests for `github.aeonbtc.ibiswallet.tor.CachingElectrumProxy` live at: +``` +app/src/test/java/github/aeonbtc/ibiswallet/tor/CachingElectrumProxyTest.kt +``` + +### What to Test + +Prefer testing **pure logic** — methods that take inputs and return outputs without side effects. For classes that do I/O (sockets, databases), test via: +1. **Mocked dependencies** — inject a `mockk()` instead of a real SQLite database. +2. **Loopback sockets** — spin up a real `ServerSocket(0)` on localhost for testing TCP protocol logic (see `CachingElectrumProxyTest`). + +Avoid testing Android UI, ViewModels with `LiveData`, and anything requiring an emulator in unit tests — those belong in instrumented tests (`androidTest/`). + +### Coverage + +Run `./gradlew jacocoUnitTestReport` to generate a coverage report. The project targets meaningful coverage of business logic classes: +- `ElectrumSeedUtil` — ~94% line coverage +- `UrAccountParser` — ~64% line coverage +- `CachingElectrumProxy` — ~47% line coverage + +--- + +## Kotlin Best Practices + +### General + +- **Prefer `val` over `var`** everywhere. Use `var` only when mutation is genuinely required. +- **Prefer data classes** for value types — they get `equals`, `hashCode`, `copy`, and `toString` for free. +- **Prefer sealed classes/interfaces** for domain-modeled state and results over nullable returns or exception-based control flow. +- **Avoid `!!` (non-null assertion)**. Use `?.let { }`, `?: return`, or `requireNotNull()` with a message instead. +- **Use `@Volatile`** for fields read/written across threads without a lock. Use `ReentrantLock` or `@Synchronized` for compound operations. +- **Scope coroutines to lifecycle owners** — never launch `GlobalScope` coroutines. Use `viewModelScope`, `lifecycleScope`, or an explicitly managed `CoroutineScope(SupervisorJob())`. +- **Use `SupervisorJob()`** in shared coroutine scopes so a failure in one child doesn't cancel siblings. + +### Coroutines + +- Use `Dispatchers.IO` for blocking I/O (sockets, file, database). Use `Dispatchers.Default` for CPU-intensive work. Never block `Dispatchers.Main`. +- Use `withContext(Dispatchers.IO) { }` to switch context within a coroutine rather than launching new coroutines unnecessarily. +- Prefer `SharedFlow` over `LiveData` for reactive streams in non-UI layers. Use `StateFlow` for observable state. +- When using `MutableSharedFlow`, set `replay = 1` if late subscribers need the last value. The default `replay = 0` with `extraBufferCapacity` only buffers for *existing* slow subscribers — values emitted before subscription are lost. +- Use `tryEmit()` for fire-and-forget emissions from non-suspending contexts. Use `emit()` from coroutines to apply backpressure. + +### Android-specific + +- **Never call `Log.*` in production code paths that execute frequently** (e.g., per-frame or per-packet). Gate all logging behind `if (BuildConfig.DEBUG)`. +- **Use `@SuppressLint` sparingly** — only when the lint warning is a confirmed false positive, and always leave a comment explaining why. +- **Prefer `EncryptedSharedPreferences`** (via `androidx.security.crypto`) for any sensitive data stored on-device. +- **Biometric authentication** — always handle `BiometricPrompt` callbacks on the main thread. + +### Jetpack Compose + +- **Hoist state** out of composables. Composables should be stateless where possible and receive state + callbacks as parameters. +- **Use `remember { }` and `derivedStateOf { }`** to avoid unnecessary recompositions. +- **Avoid side effects in composable bodies** — use `LaunchedEffect`, `SideEffect`, or `DisposableEffect` for lifecycle-aware side effects. +- **Preview with `@Preview`** for all non-trivial composables. Pass fake/stub data; never inject ViewModels into previews. +- **Navigation** — use `NavController` + `NavHost` with type-safe routes. Keep navigation logic out of ViewModels; expose `UiEvent` channels instead. + +--- + +## Architecture + +This project follows a layered architecture: + +``` +UI Layer → Composables + ViewModels (ui/) +Domain Layer → WalletRepository, pure business logic (repository, util/) +Data Layer → ElectrumCache (SQLite), EncryptedSharedPreferences, CachingElectrumProxy +Network Layer → CachingElectrumProxy (TCP/SSL/Tor), BDK ElectrumClient +``` + +**Key rules:** +- ViewModels expose `StateFlow` and handle user events via `fun onEvent(event: UiEvent)`. +- The repository layer is the single source of truth — ViewModels do not hold business logic. +- Network code (sockets, BDK calls) always runs on `Dispatchers.IO`. Results are surfaced via `SharedFlow` or `StateFlow` to the ViewModel. +- Avoid God classes — if a class exceeds ~400 lines, consider splitting by responsibility. + +--- + +## Security Considerations + +This is a **self-custody Bitcoin wallet**. Security mistakes can result in permanent loss of funds. + +- **Never log private keys, seed phrases, or xprv strings** — not even behind `BuildConfig.DEBUG`. +- **Wipe sensitive byte arrays** from memory after use (overwrite with zeros). Avoid converting seeds to `String` — strings are interned and GC-nondeterministic. +- **Tor proxy** is supported for privacy. When `useTorProxy = true`, always resolve hostnames through the SOCKS5 proxy using `InetSocketAddress.createUnresolved()` — never pre-resolve DNS on the device. +- **TOFU (Trust On First Use)** for SSL — `TofuTrustManager` pins the server certificate on first connection and rejects changes thereafter. +- **BIP39 vs Electrum seeds** are fundamentally different. Electrum seeds use a custom HMAC-based versioning scheme. Never treat them interchangeably. See `ElectrumSeedUtil` for the distinction. +- **ProGuard is enabled for release builds** — verify that BDK native library classes and any reflection-based code are properly kept in `proguard-rules.pro`. + +--- + +## Common Pitfalls + +| Pitfall | Fix | +|---|---| +| Gradle daemon uses wrong JDK | Set `org.gradle.java.home` in `gradle.properties` | +| JDK > 21 breaks Gradle 8 | Gradle 8.x supports max JDK 21. Use Temurin 21. | +| `android.util.Log` throws in unit tests | `mockkStatic(android.util.Log::class)` in `beforeSpec` | +| `org.json.JSONObject` throws in unit tests | Add `testImplementation("org.json:json:20231013")` | +| `SharedFlow` emissions lost before subscription | Start collectors before emitting, or use `replay = 1` | +| `startNotificationListener` loop exits immediately | Ensure `isRunning = true` via `start()` before calling subscription methods | +| BDK native library crashes on x86 emulator | Use ARM emulator or physical device (`abiFilters` restricts to `armeabi-v7a`, `arm64-v8a`) | +| Verbose `blockchain.transaction.get` bypasses cache | Cache only intercepts non-verbose (BDK default). Verbose queries go directly to server. | diff --git a/README.md b/README.md index cb1d144..4a24e8c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Requires Android Studio with JDK 17. ```bash ./gradlew :app:assembleDebug # Debug ./gradlew :app:assembleRelease # Release -./gradlew testDebugUnitTest # Tests +./gradlew testDebugUnitTest # Tests Report to app/build/reports/tests/testDebugUnitTest/index.html +./gradlew :app:jacocoUnitTestReport # Coverage Report to app/build/reports/jacoco/jacocoUnitTestReport/html/index.html ``` **Min SDK:** 26 (Android 8.0) | **Target SDK:** 35 | **ARM only** (armeabi-v7a, arm64-v8a) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c16800..41e1a98 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + jacoco } android { @@ -37,11 +38,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "17" + jvmTarget = "21" } buildFeatures { compose = true @@ -52,6 +53,52 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + testOptions { + unitTests.all { + it.useJUnitPlatform() + it.extensions.configure(JacocoTaskExtension::class) { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } + } + } +} + +tasks.register("jacocoUnitTestReport") { + dependsOn("testDebugUnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + } + + // Source files (Kotlin) + sourceDirectories.setFrom( + files("${projectDir}/src/main/java") + ) + + // Compiled class files — exclude generated/framework classes + val excludes = listOf( + "**/R.class", "**/R$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + "android/**/*.*", + "**/databinding/**", + "**/hilt_aggregated_deps/**", + "**/*_Hilt*", + "**/*ComposableSingletons*", + // Jetpack Compose generated + "**/*\$*\$*.*", + ) + classDirectories.setFrom( + fileTree("${buildDir}/intermediates/javac/debug/classes") { exclude(excludes) }, + fileTree("${buildDir}/tmp/kotlin-classes/debug") { exclude(excludes) }, + ) + + executionData.setFrom( + fileTree(buildDir) { include("jacoco/testDebugUnitTest.exec") } + ) } dependencies { @@ -100,5 +147,12 @@ dependencies { // BC-UR (Uniform Resources) for animated QR codes (PSBT exchange with hardware wallets) implementation(libs.hummingbird) - + // Testing + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.kotest.property) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + // Real org.json implementation for unit tests (Android stubs don't implement JSON methods) + testImplementation("org.json:json:20231013") } diff --git a/app/src/test/java/github/aeonbtc/ibiswallet/tor/CachingElectrumProxyTest.kt b/app/src/test/java/github/aeonbtc/ibiswallet/tor/CachingElectrumProxyTest.kt new file mode 100644 index 0000000..42d2f1f --- /dev/null +++ b/app/src/test/java/github/aeonbtc/ibiswallet/tor/CachingElectrumProxyTest.kt @@ -0,0 +1,916 @@ +package github.aeonbtc.ibiswallet.tor + +import github.aeonbtc.ibiswallet.data.local.ElectrumCache +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket + +/** + * Unit tests for CachingElectrumProxy. + * + * Strategy: + * - Pure data classes (ElectrumNotification, TxDetails, AddressTxInfo) — direct instantiation + * - Cache interception logic (tryServFromCache, trackTxGetRequest, tryCacheServerResponse) — + * exercised indirectly via a real loopback server socket that echoes/responds to requests + * - isServerPushNotification / dispatchPushNotification — exercised via the subscription + * socket listener using a controlled loopback server + * - parseVerboseTxDetails / getAddressTxInfo — tested by pre-populating a mocked cache + * with verbose JSON so no network is needed + * - DEFAULT_MIN_FEE_RATE constant + * - start() / stop() lifecycle — real ServerSocket on localhost:0 + */ +class CachingElectrumProxyTest : FunSpec({ + + // ─── Mock android.util.Log before any test runs ─────────────────────────── + beforeSpec { + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.e(any(), any()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 + } + + afterEach { + clearAllMocks(answers = false) // keep stubbing, clear recorded calls + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1. ElectrumNotification sealed class + // ═══════════════════════════════════════════════════════════════════════════ + context("ElectrumNotification data classes") { + + test("ScriptHashChanged holds scriptHash and status") { + val n = ElectrumNotification.ScriptHashChanged("abc123", "deadbeef") + n.scriptHash shouldBe "abc123" + n.status shouldBe "deadbeef" + } + + test("ScriptHashChanged with null status") { + val n = ElectrumNotification.ScriptHashChanged("hash", null) + n.status.shouldBeNull() + } + + test("ScriptHashChanged equality") { + val a = ElectrumNotification.ScriptHashChanged("h", "s") + val b = ElectrumNotification.ScriptHashChanged("h", "s") + a shouldBe b + } + + test("ScriptHashChanged inequality when scriptHash differs") { + val a = ElectrumNotification.ScriptHashChanged("h1", "s") + val b = ElectrumNotification.ScriptHashChanged("h2", "s") + (a == b).shouldBeFalse() + } + + test("NewBlockHeader holds height and hexHeader") { + val n = ElectrumNotification.NewBlockHeader(800_000, "deadbeef00") + n.height shouldBe 800_000 + n.hexHeader shouldBe "deadbeef00" + } + + test("NewBlockHeader equality") { + val a = ElectrumNotification.NewBlockHeader(1, "ff") + val b = ElectrumNotification.NewBlockHeader(1, "ff") + a shouldBe b + } + + test("ConnectionLost is a singleton data object") { + val a: ElectrumNotification = ElectrumNotification.ConnectionLost + val b: ElectrumNotification = ElectrumNotification.ConnectionLost + (a === b).shouldBeTrue() + } + + test("Sealed class subtypes are distinct") { + val changed: ElectrumNotification = ElectrumNotification.ScriptHashChanged("h", null) + val block: ElectrumNotification = ElectrumNotification.NewBlockHeader(1, "") + val lost: ElectrumNotification = ElectrumNotification.ConnectionLost + (changed is ElectrumNotification.ScriptHashChanged).shouldBeTrue() + (block is ElectrumNotification.NewBlockHeader).shouldBeTrue() + (lost is ElectrumNotification.ConnectionLost).shouldBeTrue() + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 2. TxDetails data class + // ═══════════════════════════════════════════════════════════════════════════ + context("TxDetails data class") { + + test("holds txid, size, vsize, weight") { + val td = CachingElectrumProxy.TxDetails("txid1", 300, 150, 600) + td.txid shouldBe "txid1" + td.size shouldBe 300 + td.vsize shouldBe 150 + td.weight shouldBe 600 + } + + test("equality") { + val a = CachingElectrumProxy.TxDetails("t", 1, 1, 4) + val b = CachingElectrumProxy.TxDetails("t", 1, 1, 4) + a shouldBe b + } + + test("copy produces independent instance") { + val original = CachingElectrumProxy.TxDetails("orig", 200, 100, 400) + val copy = original.copy(vsize = 99) + copy.vsize shouldBe 99 + original.vsize shouldBe 100 + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 3. AddressTxInfo data class + // ═══════════════════════════════════════════════════════════════════════════ + context("AddressTxInfo data class") { + + test("positive netAmountSats means received") { + val info = CachingElectrumProxy.AddressTxInfo(100_000L, 1_700_000_000L) + info.netAmountSats shouldBe 100_000L + info.timestamp shouldBe 1_700_000_000L + } + + test("negative netAmountSats means spent") { + val info = CachingElectrumProxy.AddressTxInfo(-50_000L, null) + info.netAmountSats shouldBe -50_000L + info.timestamp.shouldBeNull() + } + + test("counterpartyAddress defaults to null") { + val info = CachingElectrumProxy.AddressTxInfo(0L, null) + info.counterpartyAddress.shouldBeNull() + } + + test("feeSats defaults to null") { + val info = CachingElectrumProxy.AddressTxInfo(0L, null) + info.feeSats.shouldBeNull() + } + + test("full construction") { + val info = CachingElectrumProxy.AddressTxInfo( + netAmountSats = -10_000L, + timestamp = 1_700_000_001L, + counterpartyAddress = "bc1qrecipient", + feeSats = 500L, + ) + info.counterpartyAddress shouldBe "bc1qrecipient" + info.feeSats shouldBe 500L + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 4. DEFAULT_MIN_FEE_RATE constant + // ═══════════════════════════════════════════════════════════════════════════ + context("Constants") { + + test("DEFAULT_MIN_FEE_RATE is 1.0 sat/vByte") { + CachingElectrumProxy.DEFAULT_MIN_FEE_RATE shouldBe 1.0 + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 5. Cache interception — tryServFromCache via black-box approach + // We exercise it through the proxy's bridge by injecting a mock cache. + // ═══════════════════════════════════════════════════════════════════════════ + context("Cache interception logic (tryServFromCache / tryCacheServerResponse)") { + + /** + * Helper: build a proxy backed by a mock cache with no real network target. + * We don't call start() — we test the cache logic by calling parseVerboseTxDetails + * and getAddressTxInfo which use the verbose cache path. + */ + fun proxyWithCache(cache: ElectrumCache) = CachingElectrumProxy( + targetHost = "127.0.0.1", + targetPort = 1, // invalid port — never actually connected in these tests + cache = cache, + ) + + test("getTransactionDetails returns null when cache miss and no network") { + val cache = mockk() + every { cache.getVerboseTx(any()) } returns null + val proxy = proxyWithCache(cache) + // No direct connection so getVerboseTxJson will fail fast + val result = proxy.getTransactionDetails("nonexistenttxid") + result.shouldBeNull() + } + + test("getTransactionDetails uses cached verbose JSON (cache hit)") { + val cache = mockk() + val verboseJson = """{"size":300,"vsize":150,"weight":600}""" + every { cache.getVerboseTx("txABC") } returns verboseJson + val proxy = proxyWithCache(cache) + val result = proxy.getTransactionDetails("txABC") + result.shouldNotBeNull() + result!!.txid shouldBe "txABC" + result.size shouldBe 300 + result.vsize shouldBe 150 + result.weight shouldBe 600 + } + + test("getTransactionDetails: vsize derived from weight when vsize absent") { + val cache = mockk() + // weight=400, no vsize → calculatedVsize = (400+3)/4 = 100 + val verboseJson = """{"size":200,"weight":400}""" + every { cache.getVerboseTx("txW") } returns verboseJson + val proxy = proxyWithCache(cache) + val result = proxy.getTransactionDetails("txW") + result.shouldNotBeNull() + result!!.vsize shouldBe 100 // (400+3)/4 + result.weight shouldBe 400 + } + + test("getTransactionDetails: vsize derived from size when weight and vsize absent") { + val cache = mockk() + val verboseJson = """{"size":250}""" + every { cache.getVerboseTx("txS") } returns verboseJson + val proxy = proxyWithCache(cache) + val result = proxy.getTransactionDetails("txS") + result.shouldNotBeNull() + result!!.vsize shouldBe 250 + result.size shouldBe 250 + } + + test("getTransactionDetails returns null when JSON has no size/vsize/weight") { + val cache = mockk() + every { cache.getVerboseTx("txEmpty") } returns """{"txid":"txEmpty"}""" + val proxy = proxyWithCache(cache) + val result = proxy.getTransactionDetails("txEmpty") + result.shouldBeNull() + } + + test("getTransactionDetails: vsize takes priority over weight") { + val cache = mockk() + val verboseJson = """{"size":400,"vsize":180,"weight":720}""" + every { cache.getVerboseTx("txPriority") } returns verboseJson + val proxy = proxyWithCache(cache) + val result = proxy.getTransactionDetails("txPriority") + result.shouldNotBeNull() + result!!.vsize shouldBe 180 // vsize present → used directly + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 6. getAddressTxInfo — pure JSON calculation logic via mocked verbose cache + // ═══════════════════════════════════════════════════════════════════════════ + context("getAddressTxInfo — address amount calculations") { + + val myAddress = "bc1qmyaddress" + + fun proxyWithVerboseCache(txid: String, verboseJson: String): CachingElectrumProxy { + val cache = mockk() + every { cache.getVerboseTx(txid) } returns verboseJson + // any other txid → null (for prevout lookups) + every { cache.getVerboseTx(neq(txid)) } returns null + return CachingElectrumProxy("127.0.0.1", 1, cache = cache) + } + + test("simple receive: one output to my address") { + val verboseJson = """ + { + "blocktime": 1700000000, + "vout": [ + { + "value": 0.001, + "scriptPubKey": { "address": "$myAddress" } + } + ], + "vin": [] + } + """.trimIndent() + val proxy = proxyWithVerboseCache("txRecv", verboseJson) + val info = proxy.getAddressTxInfo("txRecv", myAddress) + info.shouldNotBeNull() + info!!.netAmountSats shouldBe 100_000L // 0.001 BTC = 100,000 sats + info.timestamp shouldBe 1700000000L + info.counterpartyAddress.shouldBeNull() // receive → no counterparty + } + + test("receive: multiple outputs, only some to my address") { + val verboseJson = """ + { + "blocktime": 1700000001, + "vout": [ + { "value": 0.005, "scriptPubKey": { "address": "$myAddress" } }, + { "value": 0.095, "scriptPubKey": { "address": "bc1qother" } } + ], + "vin": [] + } + """.trimIndent() + val proxy = proxyWithVerboseCache("txMultiOut", verboseJson) + val info = proxy.getAddressTxInfo("txMultiOut", myAddress) + info.shouldNotBeNull() + info!!.netAmountSats shouldBe 500_000L // only 0.005 BTC credited + } + + test("send: input from my address via prevout") { + val verboseJson = """ + { + "time": 1700000002, + "vout": [ + { "value": 0.009, "scriptPubKey": { "address": "bc1qrecipient" } }, + { "value": 0.0005, "scriptPubKey": { "address": "$myAddress" } } + ], + "vin": [ + { + "prevout": { + "value": 0.01, + "scriptPubKey": { "address": "$myAddress" } + } + } + ] + } + """.trimIndent() + val proxy = proxyWithVerboseCache("txSend", verboseJson) + val info = proxy.getAddressTxInfo("txSend", myAddress) + info.shouldNotBeNull() + // received 0.0005, spent 0.01 → net = 0.0005 - 0.01 = -0.0095 BTC = -950,000 sats + info!!.netAmountSats shouldBe -950_000L + info.timestamp shouldBe 1700000002L + // counterparty: first vout NOT to my address = bc1qrecipient + info.counterpartyAddress shouldBe "bc1qrecipient" + } + + test("timestamp: blocktime preferred over time") { + val verboseJson = """ + { + "blocktime": 1700000100, + "time": 1700000050, + "vout": [ + { "value": 0.001, "scriptPubKey": { "address": "$myAddress" } } + ], + "vin": [] + } + """.trimIndent() + val proxy = proxyWithVerboseCache("txTs", verboseJson) + val info = proxy.getAddressTxInfo("txTs", myAddress) + info.shouldNotBeNull() + info!!.timestamp shouldBe 1700000100L // blocktime wins + } + + test("timestamp: falls back to time when blocktime is 0") { + val verboseJson = """ + { + "blocktime": 0, + "time": 1700000099, + "vout": [ + { "value": 0.001, "scriptPubKey": { "address": "$myAddress" } } + ], + "vin": [] + } + """.trimIndent() + val proxy = proxyWithVerboseCache("txTs2", verboseJson) + val info = proxy.getAddressTxInfo("txTs2", myAddress) + info.shouldNotBeNull() + info!!.timestamp shouldBe 1700000099L + } + + test("timestamp is null when both blocktime and time are absent") { + val verboseJson = """ + { + "vout": [ + { "value": 0.001, "scriptPubKey": { "address": "$myAddress" } } + ], + "vin": [] + } + """.trimIndent() + val proxy = proxyWithVerboseCache("txNoTs", verboseJson) + val info = proxy.getAddressTxInfo("txNoTs", myAddress) + info.shouldNotBeNull() + info!!.timestamp.shouldBeNull() + } + + test("coinbase input is ignored for spend calculation") { + val verboseJson = """ + { + "blocktime": 1700000000, + "vout": [ + { "value": 6.25, "scriptPubKey": { "address": "$myAddress" } } + ], + "vin": [ + { "coinbase": "0400000000" } + ] + } + """.trimIndent() + val proxy = proxyWithVerboseCache("txCoinbase", verboseJson) + val info = proxy.getAddressTxInfo("txCoinbase", myAddress) + info.shouldNotBeNull() + info!!.netAmountSats shouldBe 625_000_000L // block reward + } + + test("getAddressTxInfo returns null when txid not in cache and no network") { + val cache = mockk() + every { cache.getVerboseTx(any()) } returns null + val proxy = CachingElectrumProxy("127.0.0.1", 1, cache = cache) + val info = proxy.getAddressTxInfo("missingtxid", myAddress) + info.shouldBeNull() + } + + test("fee calculation: inputs sum - outputs sum (prevout path)") { + val verboseJson = """ + { + "blocktime": 1700000000, + "vout": [ + { "value": 0.009, "scriptPubKey": { "address": "bc1qrecipient" } }, + { "value": 0.0009, "scriptPubKey": { "address": "$myAddress" } } + ], + "vin": [ + { + "prevout": { + "value": 0.01, + "scriptPubKey": { "address": "$myAddress" } + } + } + ] + } + """.trimIndent() + // total vin = 0.01 BTC; total vout = 0.0099 BTC; fee = 0.0001 BTC = 10,000 sats + val proxy = proxyWithVerboseCache("txFee", verboseJson) + val info = proxy.getAddressTxInfo("txFee", myAddress) + info.shouldNotBeNull() + info!!.feeSats shouldBe 10_000L + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 7. isServerPushNotification — tested via loopback proxy interaction + // We test this indirectly by verifying dispatchPushNotification emits + // the correct ElectrumNotification variants. + // ═══════════════════════════════════════════════════════════════════════════ + context("isServerPushNotification JSON detection") { + + /** + * Directly exercise the push notification dispatch path by calling + * the notifications SharedFlow. We spin up a minimal fake server that + * completes the handshake and then sends a push notification. + * + * Uses a CountDownLatch to ensure the fake server waits until the + * proxy's notification listener coroutine is running before sending + * the push notification. + */ + test("ScriptHashChanged push notification emitted from loopback server") { + val latchServerReadyForPush = java.util.concurrent.CountDownLatch(1) + + val serverSocket = ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1")) + val fakeServerPort = serverSocket.localPort + + val serverThread = Thread { + try { + val client = serverSocket.accept() + client.soTimeout = 8_000 + val reader = BufferedReader(InputStreamReader(client.getInputStream())) + val writer = PrintWriter(client.getOutputStream(), true) + + // 1. Version handshake + val versionReq = reader.readLine() ?: return@Thread + val versionId = JSONObject(versionReq).optInt("id", 200000) + writer.println("""{"jsonrpc":"2.0","id":$versionId,"result":["ElectrumX 1.16.0","1.4"]}""") + + // 2. headers.subscribe response + val headersReq = reader.readLine() ?: return@Thread + val headersId = JSONObject(headersReq).optInt("id", 200001) + writer.println("""{"jsonrpc":"2.0","id":$headersId,"result":{"height":850000,"hex":"deadbeef"}}""") + + // Wait for test to signal the listener is running + latchServerReadyForPush.await(5, java.util.concurrent.TimeUnit.SECONDS) + + // 3. Send ScriptHashChanged push notification + writer.println("""{"method":"blockchain.scripthash.subscribe","params":["abc123hash","newstatus456"],"jsonrpc":"2.0"}""") + + Thread.sleep(3000) // Keep connection alive while test reads + client.close() + } catch (_: Exception) { + } finally { + serverSocket.close() + } + } + serverThread.isDaemon = true + serverThread.start() + + val proxy = CachingElectrumProxy( + targetHost = "127.0.0.1", + targetPort = fakeServerPort, + useSsl = false, + ) + + // Collect notifications in background thread before starting subscriptions + var receivedNotification: ElectrumNotification? = null + val collectorThread = Thread { + try { + kotlinx.coroutines.runBlocking { + kotlinx.coroutines.withTimeoutOrNull(8_000L) { + proxy.notifications.collect { n -> + if (n is ElectrumNotification.ScriptHashChanged) { + receivedNotification = n + throw kotlinx.coroutines.CancellationException("found") + } + } + } + } + } catch (_: Exception) {} + } + collectorThread.isDaemon = true + collectorThread.start() + Thread.sleep(150) // Let collector subscribe to the SharedFlow + + // start() sets isRunning=true and opens the proxy server socket. + // isRunning MUST be true for the notification listener while-loop to run. + proxy.start() + + try { + // startSubscriptions blocks until handshake + header response are done, + // then launches the listener coroutine and returns + proxy.startSubscriptions(emptyList()) + + // Give the listener coroutine time to start and call readLine() + Thread.sleep(500) + + // Signal fake server: listener is now running, send the push + latchServerReadyForPush.countDown() + + // Wait for collector to receive the push notification + collectorThread.join(8_000) + + receivedNotification.shouldNotBeNull() + val changed = receivedNotification as ElectrumNotification.ScriptHashChanged + changed.scriptHash shouldBe "abc123hash" + changed.status shouldBe "newstatus456" + } finally { + latchServerReadyForPush.countDown() // unblock server if test failed early + proxy.stop() + serverThread.join(2_000) + } + } + + test("NewBlockHeader push notification emitted from loopback server") { + val latchServerReadyForPush = java.util.concurrent.CountDownLatch(1) + + val serverSocket = ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1")) + val fakeServerPort = serverSocket.localPort + + val serverThread = Thread { + try { + val client = serverSocket.accept() + client.soTimeout = 8_000 + val reader = BufferedReader(InputStreamReader(client.getInputStream())) + val writer = PrintWriter(client.getOutputStream(), true) + + // 1. Version handshake + val versionReq = reader.readLine() ?: return@Thread + val versionId = JSONObject(versionReq).optInt("id", 200000) + writer.println("""{"jsonrpc":"2.0","id":$versionId,"result":["ElectrumX 1.16.0","1.4"]}""") + + // 2. headers.subscribe response + val headersReq = reader.readLine() ?: return@Thread + val headersId = JSONObject(headersReq).optInt("id", 200001) + writer.println("""{"jsonrpc":"2.0","id":$headersId,"result":{"height":850000,"hex":"aabb"}}""") + + // Wait for test to signal the listener is running + latchServerReadyForPush.await(5, java.util.concurrent.TimeUnit.SECONDS) + + // 3. Send NewBlockHeader push notification + writer.println("""{"method":"blockchain.headers.subscribe","params":[{"height":850001,"hex":"ccdd"}],"jsonrpc":"2.0"}""") + + Thread.sleep(3000) + client.close() + } catch (_: Exception) { + } finally { + serverSocket.close() + } + } + serverThread.isDaemon = true + serverThread.start() + + val proxy = CachingElectrumProxy( + targetHost = "127.0.0.1", + targetPort = fakeServerPort, + useSsl = false, + ) + + // Collect in background, looking only for height=850001 (not the initial 850000) + var receivedBlock: ElectrumNotification.NewBlockHeader? = null + val collectorThread = Thread { + try { + kotlinx.coroutines.runBlocking { + kotlinx.coroutines.withTimeoutOrNull(8_000L) { + proxy.notifications.collect { n -> + if (n is ElectrumNotification.NewBlockHeader && n.height == 850001) { + receivedBlock = n + throw kotlinx.coroutines.CancellationException("found") + } + } + } + } + } catch (_: Exception) {} + } + collectorThread.isDaemon = true + collectorThread.start() + Thread.sleep(150) // Let collector subscribe + + // start() is required to set isRunning=true for the notification listener loop + proxy.start() + + try { + proxy.startSubscriptions(emptyList()) + + Thread.sleep(500) // Give listener coroutine time to start and block on readLine() + latchServerReadyForPush.countDown() + + collectorThread.join(8_000) + + receivedBlock.shouldNotBeNull() + receivedBlock!!.height shouldBe 850001 + receivedBlock!!.hexHeader shouldBe "ccdd" + } finally { + latchServerReadyForPush.countDown() + proxy.stop() + serverThread.join(2_000) + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 8. Lifecycle — start() and stop() + // ═══════════════════════════════════════════════════════════════════════════ + context("Lifecycle: start() and stop()") { + + test("start() returns a positive local port") { + val proxy = CachingElectrumProxy("127.0.0.1", 9999) + val port = proxy.start() + try { + (port > 0).shouldBeTrue() + (port <= 65535).shouldBeTrue() + } finally { + proxy.stop() + } + } + + test("start() twice returns the same port (idempotent)") { + val proxy = CachingElectrumProxy("127.0.0.1", 9999) + val port1 = proxy.start() + val port2 = proxy.start() // already running + try { + port1 shouldBe port2 + } finally { + proxy.stop() + } + } + + test("stop() can be called without start()") { + val proxy = CachingElectrumProxy("127.0.0.1", 9999) + // Should not throw + proxy.stop() + } + + test("stop() followed by start() works (restart scenario)") { + val proxy = CachingElectrumProxy("127.0.0.1", 9999) + val port1 = proxy.start() + proxy.stop() + val port2 = proxy.start() + try { + // Both should be valid OS-assigned ports; they may differ + (port2 > 0).shouldBeTrue() + } finally { + proxy.stop() + } + } + + test("server socket is accessible after start()") { + val proxy = CachingElectrumProxy("127.0.0.1", 9999) + val port = proxy.start() + try { + // We should be able to connect a raw socket to the proxy port + val socket = Socket("127.0.0.1", port) + socket.isConnected.shouldBeTrue() + socket.close() + } finally { + proxy.stop() + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 9. checkForScriptHashChanges — pure logic (no network needed for edge cases) + // ═══════════════════════════════════════════════════════════════════════════ + context("checkForScriptHashChanges edge cases") { + + test("returns true immediately for empty cachedStatuses") { + // Empty map → no script hashes to check → returns true (conservative) + val proxy = CachingElectrumProxy("127.0.0.1", 1) + val result = proxy.checkForScriptHashChanges(emptyMap()) + result.shouldBeTrue() + } + + test("returns true when subscribeScriptHashes fails (no network)") { + // No server running → subscribeScriptHashes returns empty → returns true + val proxy = CachingElectrumProxy("127.0.0.1", 1, connectionTimeoutMs = 100, soTimeoutMs = 100) + val result = proxy.checkForScriptHashChanges(mapOf("abc" to "status")) + result.shouldBeTrue() + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 10. Cache mock interactions via getTransactionDetails + // ═══════════════════════════════════════════════════════════════════════════ + context("ElectrumCache mock interactions") { + + test("getTransactionDetails calls getVerboseTx on the cache") { + val cache = mockk() + every { cache.getVerboseTx("myTxId") } returns """{"size":100,"vsize":50,"weight":200}""" + val proxy = CachingElectrumProxy("127.0.0.1", 1, cache = cache) + proxy.getTransactionDetails("myTxId") + verify(exactly = 1) { cache.getVerboseTx("myTxId") } + } + + test("getTransactionDetails does NOT call putVerboseTx when cache hit") { + val cache = mockk() + every { cache.getVerboseTx(any()) } returns """{"size":100,"vsize":50,"weight":200}""" + val proxy = CachingElectrumProxy("127.0.0.1", 1, cache = cache) + proxy.getTransactionDetails("cachedTx") + // No network call happened → no put + verify(exactly = 0) { cache.putVerboseTx(any(), any(), any()) } + } + + test("getAddressTxInfo returns null when cache returns invalid JSON") { + val cache = mockk() + every { cache.getVerboseTx(any()) } returns "not-json" + val proxy = CachingElectrumProxy("127.0.0.1", 1, cache = cache) + val result = proxy.getAddressTxInfo("badJson", "bc1qaddr") + result.shouldBeNull() + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 11. Bridge cache interception — loopback integration test + // Verifies that cache HITS are served without forwarding to the server, + // and cache MISSes are forwarded and the response is stored. + // ═══════════════════════════════════════════════════════════════════════════ + context("Bridge cache interception (loopback integration)") { + + /** + * Spins up: + * 1. A fake Electrum server that records received requests and replies + * 2. The CachingElectrumProxy (with a mock cache) bridging to the fake server + * Returns (fakeServerRequests, proxy, proxyPort). + */ + fun buildBridgedProxy(cache: ElectrumCache): Triple, CachingElectrumProxy, Int> { + val receivedByFakeServer = mutableListOf() + + // Fake Electrum server + val fakeServer = ServerSocket(0, 5, java.net.InetAddress.getByName("127.0.0.1")) + val fakePort = fakeServer.localPort + + val serverThread = Thread { + try { + val client = fakeServer.accept() + client.soTimeout = 3_000 + val reader = BufferedReader(InputStreamReader(client.getInputStream())) + val writer = PrintWriter(client.getOutputStream(), true) + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + synchronized(receivedByFakeServer) { receivedByFakeServer.add(line) } + val json = JSONObject(line) + val id = json.optInt("id", 0) + val method = json.optString("method", "") + when { + method == "blockchain.transaction.get" -> { + val txid = json.optJSONArray("params")?.optString(0) ?: "unknown" + writer.println("""{"jsonrpc":"2.0","id":$id,"result":"deadbeef$txid"}""") + } + else -> writer.println("""{"jsonrpc":"2.0","id":$id,"result":null}""") + } + } + } catch (_: Exception) { + } finally { + try { fakeServer.close() } catch (_: Exception) {} + } + } + serverThread.isDaemon = true + serverThread.start() + + val proxy = CachingElectrumProxy( + targetHost = "127.0.0.1", + targetPort = fakePort, + cache = cache, + ) + val proxyPort = proxy.start() + return Triple(receivedByFakeServer, proxy, proxyPort) + } + + test("cache miss: blockchain.transaction.get forwarded to server") { + val cache = mockk() + every { cache.getRawTx(any()) } returns null + every { cache.putRawTx(any(), any()) } returns Unit + + val (serverRequests, proxy, proxyPort) = buildBridgedProxy(cache) + try { + val client = Socket("127.0.0.1", proxyPort) + client.soTimeout = 3_000 + val writer = PrintWriter(client.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(client.getInputStream())) + + val request = """{"id":1,"method":"blockchain.transaction.get","params":["aabbccddeeff"]}""" + writer.println(request) + + val response = reader.readLine() + response.shouldNotBeNull() + response!! shouldContain "\"result\"" + + client.close() + Thread.sleep(100) + + // The fake server should have received the request + synchronized(serverRequests) { + serverRequests.any { it.contains("blockchain.transaction.get") }.shouldBeTrue() + } + } finally { + proxy.stop() + } + } + + test("cache hit: blockchain.transaction.get NOT forwarded to server") { + val cache = mockk() + val cachedHex = "cafebabe01020304" + every { cache.getRawTx("txCacheHit") } returns cachedHex + + val (serverRequests, proxy, proxyPort) = buildBridgedProxy(cache) + try { + val client = Socket("127.0.0.1", proxyPort) + client.soTimeout = 3_000 + val writer = PrintWriter(client.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(client.getInputStream())) + + val request = """{"id":42,"method":"blockchain.transaction.get","params":["txCacheHit"]}""" + writer.println(request) + + val response = reader.readLine() + response.shouldNotBeNull() + val responseJson = JSONObject(response!!) + responseJson.optString("result") shouldBe cachedHex + responseJson.optInt("id") shouldBe 42 + + client.close() + Thread.sleep(100) + + // The fake server should NOT have received this request + synchronized(serverRequests) { + serverRequests.none { line -> + JSONObject(line).optJSONArray("params")?.optString(0) == "txCacheHit" + }.shouldBeTrue() + } + } finally { + proxy.stop() + } + } + + test("verbose=true request is NOT intercepted by cache") { + val cache = mockk() + // Even if a raw tx exists, verbose requests skip the cache + every { cache.getRawTx("txVerbose") } returns "someHex" + every { cache.putRawTx(any(), any()) } returns Unit + + val (serverRequests, proxy, proxyPort) = buildBridgedProxy(cache) + try { + val client = Socket("127.0.0.1", proxyPort) + client.soTimeout = 3_000 + val writer = PrintWriter(client.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(client.getInputStream())) + + // Verbose=true → proxy should NOT serve from cache + val request = """{"id":7,"method":"blockchain.transaction.get","params":["txVerbose",true]}""" + writer.println(request) + + val response = reader.readLine() + response.shouldNotBeNull() + + client.close() + Thread.sleep(100) + + // Server should have received it (not intercepted) + synchronized(serverRequests) { + serverRequests.any { line -> + val j = JSONObject(line) + j.optString("method") == "blockchain.transaction.get" && + j.optJSONArray("params")?.optString(0) == "txVerbose" + }.shouldBeTrue() + } + } finally { + proxy.stop() + } + } + } +}) diff --git a/app/src/test/java/github/aeonbtc/ibiswallet/util/ElectrumSeedUtilTest.kt b/app/src/test/java/github/aeonbtc/ibiswallet/util/ElectrumSeedUtilTest.kt new file mode 100644 index 0000000..7b2821b --- /dev/null +++ b/app/src/test/java/github/aeonbtc/ibiswallet/util/ElectrumSeedUtilTest.kt @@ -0,0 +1,393 @@ +package github.aeonbtc.ibiswallet.util + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.collections.shouldHaveSize + +/** + * Unit tests for ElectrumSeedUtil. + * + * Test vectors sourced from: + * - Electrum source: lib/mnemonic.py and tests/test_wallet_creation.py + * - https://electrum.readthedocs.io/en/latest/seedphrase.html + * + * These are deterministic crypto operations — the expected outputs are fixed + * reference values and must never change without a corresponding protocol change. + */ +class ElectrumSeedUtilTest : FunSpec({ + + // ── normalizeText ───────────────────────────────────────────────────────── + + context("normalizeText") { + + test("lowercases all characters") { + ElectrumSeedUtil.normalizeText("HELLO WORLD") shouldBe "hello world" + } + + test("collapses multiple spaces to single space") { + ElectrumSeedUtil.normalizeText("hello world") shouldBe "hello world" + } + + test("trims leading and trailing whitespace") { + ElectrumSeedUtil.normalizeText(" hello world ") shouldBe "hello world" + } + + test("strips accent/diacritic combining characters after NFKD") { + // "é" NFKD-decomposes to "e" + combining acute — the combining mark should be stripped + ElectrumSeedUtil.normalizeText("café") shouldBe "cafe" + } + + test("normalizes tab and newline whitespace to single space") { + ElectrumSeedUtil.normalizeText("hello\tworld\nfoo") shouldBe "hello world foo" + } + + test("returns empty string for blank input") { + ElectrumSeedUtil.normalizeText(" ") shouldBe "" + } + + test("removes spaces between CJK characters") { + // Two CJK ideographs with a space between them — space should be removed + ElectrumSeedUtil.normalizeText("\u4e2d \u6587") shouldBe "\u4e2d\u6587" + } + + test("preserves spaces between non-CJK words") { + ElectrumSeedUtil.normalizeText("hello world") shouldBe "hello world" + } + + test("handles already normalized ASCII seed phrase unchanged") { + val seed = "duck butter fatal edit canyon finance aspect slight" + ElectrumSeedUtil.normalizeText(seed) shouldBe seed + } + } + + // ── getElectrumSeedType ─────────────────────────────────────────────────── + + context("getElectrumSeedType") { + + // Known Electrum STANDARD seed (HMAC prefix starts with "01") + // Verified by brute-force against ElectrumSeedUtil.getElectrumSeedType. + val standardSeed = "absent afraid wild father tree among universe such mobile favorite target dynamic" + + // Known Electrum SEGWIT seed (HMAC prefix starts with "100") + val segwitSeed = "wild father tree among universe such mobile favorite target dynamic credit identify" + + test("detects a known standard seed as STANDARD") { + ElectrumSeedUtil.getElectrumSeedType(standardSeed) shouldBe + ElectrumSeedUtil.ElectrumSeedType.STANDARD + } + + test("detects a known segwit seed as SEGWIT") { + ElectrumSeedUtil.getElectrumSeedType(segwitSeed) shouldBe + ElectrumSeedUtil.ElectrumSeedType.SEGWIT + } + + test("returns null for a random BIP39 mnemonic (not an Electrum seed)") { + // A standard BIP39 12-word phrase — should NOT match any Electrum prefix + val bip39 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ElectrumSeedUtil.getElectrumSeedType(bip39).shouldBeNull() + } + + test("returns null for empty input") { + ElectrumSeedUtil.getElectrumSeedType("").shouldBeNull() + } + + test("returns null for blank input") { + ElectrumSeedUtil.getElectrumSeedType(" ").shouldBeNull() + } + + test("is case-insensitive (normalizes before hashing)") { + // Uppercased version of the standard seed must detect the same type + ElectrumSeedUtil.getElectrumSeedType(standardSeed.uppercase()) shouldBe + ElectrumSeedUtil.ElectrumSeedType.STANDARD + } + + test("is whitespace-tolerant (extra spaces still match)") { + // Extra spaces between words should normalize away before hashing + val withExtraSpaces = standardSeed.replace(" ", " ") + ElectrumSeedUtil.getElectrumSeedType(withExtraSpaces) shouldBe + ElectrumSeedUtil.ElectrumSeedType.STANDARD + } + } + + // ── mnemonicToSeed ──────────────────────────────────────────────────────── + + context("mnemonicToSeed") { + + val standardSeed = "absent afraid wild father tree among universe such mobile favorite target dynamic" + + test("returns a 64-byte array") { + val result = ElectrumSeedUtil.mnemonicToSeed(standardSeed) + result.size shouldBe 64 + } + + test("is deterministic — same input produces same output") { + val a = ElectrumSeedUtil.mnemonicToSeed(standardSeed) + val b = ElectrumSeedUtil.mnemonicToSeed(standardSeed) + a shouldBe b + } + + test("passphrase changes the derived seed") { + val noPass = ElectrumSeedUtil.mnemonicToSeed(standardSeed) + val withPass = ElectrumSeedUtil.mnemonicToSeed(standardSeed, "hunter2") + (noPass contentEquals withPass) shouldBe false + } + + test("null and empty passphrase produce the same seed") { + val nullPass = ElectrumSeedUtil.mnemonicToSeed(standardSeed, null) + val emptyPass = ElectrumSeedUtil.mnemonicToSeed(standardSeed, "") + nullPass shouldBe emptyPass + } + + test("different mnemonics produce different seeds") { + val segwitSeed = "wild father tree among universe such mobile favorite target dynamic credit identify" + val a = ElectrumSeedUtil.mnemonicToSeed(standardSeed) + val b = ElectrumSeedUtil.mnemonicToSeed(segwitSeed) + (a contentEquals b) shouldBe false + } + } + + // ── masterKeyFromSeed ───────────────────────────────────────────────────── + + context("masterKeyFromSeed") { + + val testSeed = ElectrumSeedUtil.mnemonicToSeed( + "absent afraid wild father tree among universe such mobile favorite target dynamic" + ) + + test("returns private key of exactly 32 bytes") { + val (privateKey, _) = ElectrumSeedUtil.masterKeyFromSeed(testSeed) + privateKey.size shouldBe 32 + } + + test("returns chain code of exactly 32 bytes") { + val (_, chainCode) = ElectrumSeedUtil.masterKeyFromSeed(testSeed) + chainCode.size shouldBe 32 + } + + test("is deterministic") { + val (k1, c1) = ElectrumSeedUtil.masterKeyFromSeed(testSeed) + val (k2, c2) = ElectrumSeedUtil.masterKeyFromSeed(testSeed) + k1 shouldBe k2 + c1 shouldBe c2 + } + + test("different seeds produce different master keys") { + val seed2 = ElectrumSeedUtil.mnemonicToSeed( + "wild father tree among universe such mobile favorite target dynamic credit identify" + ) + val (k1, _) = ElectrumSeedUtil.masterKeyFromSeed(testSeed) + val (k2, _) = ElectrumSeedUtil.masterKeyFromSeed(seed2) + (k1 contentEquals k2) shouldBe false + } + } + + // ── computeMasterFingerprint ────────────────────────────────────────────── + + context("computeMasterFingerprint") { + + val testSeed = ElectrumSeedUtil.mnemonicToSeed( + "absent afraid wild father tree among universe such mobile favorite target dynamic" + ) + + test("returns an 8-character lowercase hex string") { + val fp = ElectrumSeedUtil.computeMasterFingerprint(testSeed) + fp.length shouldBe 8 + fp shouldBe fp.lowercase() + fp.all { it.isDigit() || it in 'a'..'f' } shouldBe true + } + + test("is deterministic") { + val fp1 = ElectrumSeedUtil.computeMasterFingerprint(testSeed) + val fp2 = ElectrumSeedUtil.computeMasterFingerprint(testSeed) + fp1 shouldBe fp2 + } + + test("differs between seeds") { + val seed2 = ElectrumSeedUtil.mnemonicToSeed( + "wild father tree among universe such mobile favorite target dynamic credit identify" + ) + val fp1 = ElectrumSeedUtil.computeMasterFingerprint(testSeed) + val fp2 = ElectrumSeedUtil.computeMasterFingerprint(seed2) + fp1 shouldBe fp1 // sanity + (fp1 == fp2) shouldBe false + } + } + + // ── deriveExtendedPublicKey ─────────────────────────────────────────────── + + context("deriveExtendedPublicKey") { + + val standardSeedPhrase = "absent afraid wild father tree among universe such mobile favorite target dynamic" + val segwitSeedPhrase = "wild father tree among universe such mobile favorite target dynamic credit identify" + + val standardSeed = ElectrumSeedUtil.mnemonicToSeed(standardSeedPhrase) + val segwitSeed = ElectrumSeedUtil.mnemonicToSeed(segwitSeedPhrase) + + test("STANDARD seed produces an xpub (starts with 'xpub')") { + val xpub = ElectrumSeedUtil.deriveExtendedPublicKey( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + xpub shouldStartWith "xpub" + } + + test("SEGWIT seed produces a zpub (starts with 'zpub')") { + val zpub = ElectrumSeedUtil.deriveExtendedPublicKey( + segwitSeed, + ElectrumSeedUtil.ElectrumSeedType.SEGWIT + ) + zpub shouldStartWith "zpub" + } + + test("is deterministic") { + val a = ElectrumSeedUtil.deriveExtendedPublicKey( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + val b = ElectrumSeedUtil.deriveExtendedPublicKey( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + a shouldBe b + } + + test("different seeds produce different extended public keys") { + val xpub1 = ElectrumSeedUtil.deriveExtendedPublicKey( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + val xpub2 = ElectrumSeedUtil.deriveExtendedPublicKey( + segwitSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + (xpub1 == xpub2) shouldBe false + } + } + + // ── buildDescriptorStrings ──────────────────────────────────────────────── + + context("buildDescriptorStrings") { + + val standardSeed = ElectrumSeedUtil.mnemonicToSeed( + "absent afraid wild father tree among universe such mobile favorite target dynamic" + ) + val segwitSeed = ElectrumSeedUtil.mnemonicToSeed( + "wild father tree among universe such mobile favorite target dynamic credit identify" + ) + + test("STANDARD type produces pkh() descriptors") { + val (ext, int) = ElectrumSeedUtil.buildDescriptorStrings( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + ext shouldStartWith "pkh(" + int shouldStartWith "pkh(" + } + + test("SEGWIT type produces wpkh() descriptors") { + val (ext, int) = ElectrumSeedUtil.buildDescriptorStrings( + segwitSeed, + ElectrumSeedUtil.ElectrumSeedType.SEGWIT + ) + ext shouldStartWith "wpkh(" + int shouldStartWith "wpkh(" + } + + test("external descriptor ends with /0/*)") { + val (ext, _) = ElectrumSeedUtil.buildDescriptorStrings( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + ext.endsWith("/0/*)") shouldBe true + } + + test("internal descriptor ends with /1/*)") { + val (_, int) = ElectrumSeedUtil.buildDescriptorStrings( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + int.endsWith("/1/*)") shouldBe true + } + + test("descriptors contain the master fingerprint") { + val fingerprint = ElectrumSeedUtil.computeMasterFingerprint(standardSeed) + val (ext, int) = ElectrumSeedUtil.buildDescriptorStrings( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + ext.contains(fingerprint) shouldBe true + int.contains(fingerprint) shouldBe true + } + + test("SEGWIT descriptors contain the master fingerprint with /0' path") { + val fingerprint = ElectrumSeedUtil.computeMasterFingerprint(segwitSeed) + val (ext, _) = ElectrumSeedUtil.buildDescriptorStrings( + segwitSeed, + ElectrumSeedUtil.ElectrumSeedType.SEGWIT + ) + ext.contains("$fingerprint/0'") shouldBe true + } + + test("is deterministic") { + val r1 = ElectrumSeedUtil.buildDescriptorStrings( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + val r2 = ElectrumSeedUtil.buildDescriptorStrings( + standardSeed, + ElectrumSeedUtil.ElectrumSeedType.STANDARD + ) + r1 shouldBe r2 + } + } + + // ── deriveHardenedChild ─────────────────────────────────────────────────── + + context("deriveHardenedChild") { + + val testSeed = ElectrumSeedUtil.mnemonicToSeed( + "absent afraid wild father tree among universe such mobile favorite target dynamic" + ) + val (masterKey, masterChainCode) = ElectrumSeedUtil.masterKeyFromSeed(testSeed) + + test("returns child key of 32 bytes") { + val (childKey, _) = ElectrumSeedUtil.deriveHardenedChild( + masterKey, masterChainCode, 0x80000000L + ) + childKey.size shouldBe 32 + } + + test("returns child chain code of 32 bytes") { + val (_, childChainCode) = ElectrumSeedUtil.deriveHardenedChild( + masterKey, masterChainCode, 0x80000000L + ) + childChainCode.size shouldBe 32 + } + + test("is deterministic") { + val r1 = ElectrumSeedUtil.deriveHardenedChild(masterKey, masterChainCode, 0x80000000L) + val r2 = ElectrumSeedUtil.deriveHardenedChild(masterKey, masterChainCode, 0x80000000L) + r1.first shouldBe r2.first + r1.second shouldBe r2.second + } + + test("different indices produce different child keys") { + val (k0, _) = ElectrumSeedUtil.deriveHardenedChild(masterKey, masterChainCode, 0x80000000L) + val (k1, _) = ElectrumSeedUtil.deriveHardenedChild(masterKey, masterChainCode, 0x80000001L) + (k0 contentEquals k1) shouldBe false + } + + test("throws on non-hardened index") { + val threw = try { + ElectrumSeedUtil.deriveHardenedChild(masterKey, masterChainCode, 0L) + false + } catch (e: IllegalArgumentException) { + true + } + threw shouldBe true + } + } +}) diff --git a/app/src/test/java/github/aeonbtc/ibiswallet/util/QrFormatParserTest.kt b/app/src/test/java/github/aeonbtc/ibiswallet/util/QrFormatParserTest.kt new file mode 100644 index 0000000..9377571 --- /dev/null +++ b/app/src/test/java/github/aeonbtc/ibiswallet/util/QrFormatParserTest.kt @@ -0,0 +1,122 @@ +package github.aeonbtc.ibiswallet.util + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.booleans.shouldBeFalse + +class QrFormatParserTest : FunSpec({ + + // ------------------------------------------------------------------------- + // parseServerQr — plain host:port + // ------------------------------------------------------------------------- + + context("parseServerQr - plain host:port") { + test("parses host and numeric port") { + val result = QrFormatParser.parseServerQr("electrum.example.com:50002") + result.host shouldBe "electrum.example.com" + result.port shouldBe 50002 + } + + test("infers SSL=true for port 50002") { + val result = QrFormatParser.parseServerQr("electrum.example.com:50002") + result.ssl!!.shouldBeTrue() + } + + test("infers SSL=false for port 50001") { + val result = QrFormatParser.parseServerQr("electrum.example.com:50001") + result.ssl!!.shouldBeFalse() + } + + test("trims whitespace around input") { + val result = QrFormatParser.parseServerQr(" electrum.example.com:50002 ") + result.host shouldBe "electrum.example.com" + result.port shouldBe 50002 + } + } + + // ------------------------------------------------------------------------- + // parseServerQr — protocol-prefixed + // ------------------------------------------------------------------------- + + context("parseServerQr - ssl:// prefix") { + test("strips ssl:// prefix and sets ssl=true") { + val result = QrFormatParser.parseServerQr("ssl://electrum.example.com:50002") + result.host shouldBe "electrum.example.com" + result.port shouldBe 50002 + result.ssl!!.shouldBeTrue() + } + } + + context("parseServerQr - tcp:// prefix") { + test("strips tcp:// prefix and sets ssl=false") { + val result = QrFormatParser.parseServerQr("tcp://electrum.example.com:50001") + result.host shouldBe "electrum.example.com" + result.port shouldBe 50001 + result.ssl!!.shouldBeFalse() + } + } + + context("parseServerQr - https:// prefix") { + test("strips https:// and sets ssl=true") { + val result = QrFormatParser.parseServerQr("https://mynode.local:443") + result.host shouldBe "mynode.local" + result.port shouldBe 443 + result.ssl!!.shouldBeTrue() + } + } + + context("parseServerQr - http:// prefix") { + test("strips http:// and sets ssl=false") { + val result = QrFormatParser.parseServerQr("http://mynode.local:8080") + result.host shouldBe "mynode.local" + result.port shouldBe 8080 + result.ssl!!.shouldBeFalse() + } + } + + // ------------------------------------------------------------------------- + // parseServerQr — Electrum-style host:port:s / host:port:t suffix + // ------------------------------------------------------------------------- + + context("parseServerQr - Electrum :s/:t suffix") { + test("host:port:s sets ssl=true") { + val result = QrFormatParser.parseServerQr("electrum.example.com:50002:s") + result.host shouldBe "electrum.example.com" + result.port shouldBe 50002 + result.ssl!!.shouldBeTrue() + } + + test("host:port:t sets ssl=false") { + val result = QrFormatParser.parseServerQr("electrum.example.com:50001:t") + result.host shouldBe "electrum.example.com" + result.port shouldBe 50001 + result.ssl!!.shouldBeFalse() + } + } + + // ------------------------------------------------------------------------- + // parseServerQr — no port + // ------------------------------------------------------------------------- + + context("parseServerQr - host only, no port") { + test("returns host with null port") { + val result = QrFormatParser.parseServerQr("electrum.example.com") + result.host shouldBe "electrum.example.com" + result.port.shouldBeNull() + } + } + + // ------------------------------------------------------------------------- + // parseServerQr — trailing slash stripped + // ------------------------------------------------------------------------- + + context("parseServerQr - trailing slash") { + test("strips trailing slash from host:port") { + val result = QrFormatParser.parseServerQr("electrum.example.com:50002/") + result.host shouldBe "electrum.example.com" + result.port shouldBe 50002 + } + } +}) diff --git a/app/src/test/java/github/aeonbtc/ibiswallet/util/UrAccountParserTest.kt b/app/src/test/java/github/aeonbtc/ibiswallet/util/UrAccountParserTest.kt new file mode 100644 index 0000000..83e966a --- /dev/null +++ b/app/src/test/java/github/aeonbtc/ibiswallet/util/UrAccountParserTest.kt @@ -0,0 +1,433 @@ +package github.aeonbtc.ibiswallet.util + +import com.sparrowwallet.hummingbird.UR +import com.sparrowwallet.hummingbird.registry.CryptoAccount +import com.sparrowwallet.hummingbird.registry.CryptoHDKey +import com.sparrowwallet.hummingbird.registry.CryptoOutput +import com.sparrowwallet.hummingbird.registry.CryptoCoinInfo +import com.sparrowwallet.hummingbird.registry.ScriptExpression +import com.sparrowwallet.hummingbird.registry.URAccountDescriptor +import com.sparrowwallet.hummingbird.registry.UROutputDescriptor +import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent +import github.aeonbtc.ibiswallet.data.model.AddressType +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic + +/** + * Unit tests for UrAccountParser. + * + * Because UrAccountParser depends on Hummingbird (Blockchain Commons UR) registry objects, + * we use MockK to build lightweight fakes rather than constructing real CBOR payloads. + * + * Test strategy: + * - parseUr() routing: verify correct branch is selected for each UR type string + * - ParsedUrResult data class: verify fields are populated correctly + * - sourceToAddressType (private): exercised indirectly via v2 account-descriptor path + * - scriptExpressionsToAddressType (private): exercised via crypto-output path + * - Null/error handling: unsupported types, malformed objects return null safely + */ +class UrAccountParserTest : FunSpec({ + + // Mock android.util.Log for all tests — it is not available in the JVM unit test environment + beforeSpec { + mockkStatic(android.util.Log::class) + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.e(any(), any()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 + every { android.util.Log.d(any(), any()) } returns 0 + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Build a minimal mock CryptoHDKey with the fields needed for xpub reconstruction. + * Uses a real 33-byte compressed public key and 32-byte chain code so that + * base58Check encoding can run without throwing. + * + * The key bytes below are the secp256k1 generator point G compressed — + * a publicly known point that is safe to use as a test fixture. + */ + fun minimalHdKeyMock( + fingerprint: ByteArray = byteArrayOf(0x37, 0xb5.toByte(), 0xee.toByte(), 0xd4.toByte()), + path: String? = "84'/0'/0'", + isTestnet: Boolean = false, + ): CryptoHDKey { + val mock = mockk(relaxed = true) + + // 33-byte compressed public key (generator point G, prefix 0x02) + val keyData = byteArrayOf(0x02) + ByteArray(32) { (it + 1).toByte() } + // 32-byte chain code + val chainCode = ByteArray(32) { (it + 0x10).toByte() } + + val origin = mockk(relaxed = true) + every { origin.sourceFingerprint } returns fingerprint + every { origin.path } returns path + every { origin.depth } returns (path?.count { it == '/' } ?: 0) + every { origin.components } returns emptyList() + + val useInfo = mockk(relaxed = true) + every { useInfo.network } returns + if (isTestnet) CryptoCoinInfo.Network.TESTNET else CryptoCoinInfo.Network.MAINNET + + every { mock.key } returns keyData + every { mock.chainCode } returns chainCode + every { mock.origin } returns origin + every { mock.useInfo } returns useInfo + every { mock.parentFingerprint } returns byteArrayOf(0x00, 0x00, 0x00, 0x00) + every { mock.children } returns null + + return mock + } + + // ── ParsedUrResult data class ───────────────────────────────────────────── + + context("ParsedUrResult") { + + test("holds keyMaterial, fingerprint, and detectedAddressType") { + val result = UrAccountParser.ParsedUrResult( + keyMaterial = "wpkh([deadbeef/84'/0'/0']xpub.../0/*)", + fingerprint = "deadbeef", + detectedAddressType = AddressType.SEGWIT, + ) + result.keyMaterial shouldBe "wpkh([deadbeef/84'/0'/0']xpub.../0/*)" + result.fingerprint shouldBe "deadbeef" + result.detectedAddressType shouldBe AddressType.SEGWIT + } + + test("detectedAddressType defaults to null") { + val result = UrAccountParser.ParsedUrResult( + keyMaterial = "xpub123", + fingerprint = null, + ) + result.detectedAddressType shouldBe null + } + + test("fingerprint can be null") { + val result = UrAccountParser.ParsedUrResult( + keyMaterial = "xpub123", + fingerprint = null, + ) + result.fingerprint shouldBe null + } + } + + // ── parseUr — unsupported type ──────────────────────────────────────────── + + context("parseUr - unsupported UR type") { + + test("returns null for unknown UR type") { + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-seed" + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldBeNull() + } + + test("returns null for empty UR type string") { + val ur = mockk(relaxed = true) + every { ur.type } returns "" + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldBeNull() + } + } + + // ── parseUr — crypto-hdkey (v1) ─────────────────────────────────────────── + + context("parseUr - crypto-hdkey") { + + test("returns non-null result for a well-formed crypto-hdkey") { + val hdKey = minimalHdKeyMock() + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-hdkey" + every { ur.decodeFromRegistry() } returns hdKey + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + } + + test("extracts fingerprint from origin") { + val expectedFp = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + val hdKey = minimalHdKeyMock(fingerprint = expectedFp) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-hdkey" + every { ur.decodeFromRegistry() } returns hdKey + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.fingerprint shouldBe "deadbeef" + } + + test("keyMaterial contains fingerprint in key-origin format") { + val fp = byteArrayOf(0x37, 0xb5.toByte(), 0xee.toByte(), 0xd4.toByte()) + val hdKey = minimalHdKeyMock(fingerprint = fp, path = "84'/0'/0'") + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-hdkey" + every { ur.decodeFromRegistry() } returns hdKey + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.keyMaterial shouldContain "37b5eed4" + } + + test("keyMaterial starts with xpub for mainnet key") { + val hdKey = minimalHdKeyMock(isTestnet = false) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-hdkey" + every { ur.decodeFromRegistry() } returns hdKey + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + // key-origin format: "[fp/path]xpub..." or bare "xpub..." + (result.keyMaterial.contains("xpub") || result.keyMaterial.startsWith("[")) shouldBe true + } + + test("testnet key produces tpub in keyMaterial") { + val hdKey = minimalHdKeyMock(isTestnet = true) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-hdkey" + every { ur.decodeFromRegistry() } returns hdKey + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.keyMaterial shouldContain "tpub" + } + + test("returns null if UR decodes to wrong type") { + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-hdkey" + every { ur.decodeFromRegistry() } returns "not a CryptoHDKey" + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldBeNull() + } + } + + // ── parseUr — hdkey (v2) ────────────────────────────────────────────────── + + context("parseUr - hdkey (v2)") { + + test("routes hdkey type and returns result equivalent to crypto-hdkey") { + val hdKey = minimalHdKeyMock() + val ur = mockk(relaxed = true) + every { ur.type } returns "hdkey" + every { ur.decodeFromRegistry() } returns hdKey + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + } + } + + // ── parseUr — crypto-output (v1) ───────────────────────────────────────── + + context("parseUr - crypto-output") { + + fun makeCryptoOutput(expressions: List): CryptoOutput { + val hdKey = minimalHdKeyMock() + val output = mockk(relaxed = true) + every { output.hdKey } returns hdKey + every { output.scriptExpressions } returns expressions + return output + } + + test("returns SEGWIT address type for WITNESS_PUBLIC_KEY_HASH expression") { + val output = makeCryptoOutput(listOf(ScriptExpression.WITNESS_PUBLIC_KEY_HASH)) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-output" + every { ur.decodeFromRegistry() } returns output + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.SEGWIT + } + + test("returns NESTED_SEGWIT for SCRIPT_HASH + WITNESS_PUBLIC_KEY_HASH") { + val output = makeCryptoOutput( + listOf(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH) + ) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-output" + every { ur.decodeFromRegistry() } returns output + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.NESTED_SEGWIT + } + + test("returns LEGACY for PUBLIC_KEY_HASH expression") { + val output = makeCryptoOutput(listOf(ScriptExpression.PUBLIC_KEY_HASH)) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-output" + every { ur.decodeFromRegistry() } returns output + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.LEGACY + } + + test("returns TAPROOT for TAPROOT expression") { + val output = makeCryptoOutput(listOf(ScriptExpression.TAPROOT)) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-output" + every { ur.decodeFromRegistry() } returns output + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.TAPROOT + } + + test("keyMaterial contains wpkh() wrapper for SEGWIT") { + val output = makeCryptoOutput(listOf(ScriptExpression.WITNESS_PUBLIC_KEY_HASH)) + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-output" + every { ur.decodeFromRegistry() } returns output + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.keyMaterial shouldStartWith "wpkh(" + } + + test("returns null when hdKey is missing from output") { + val output = mockk(relaxed = true) + every { output.hdKey } returns null + every { output.scriptExpressions } returns listOf(ScriptExpression.WITNESS_PUBLIC_KEY_HASH) + + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-output" + every { ur.decodeFromRegistry() } returns output + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldBeNull() + } + } + + // ── parseUr — output-descriptor (v2) ───────────────────────────────────── + + context("parseUr - output-descriptor (v2)") { + + test("returns SEGWIT when source starts with wpkh(") { + val hdKey = minimalHdKeyMock() + val desc = mockk(relaxed = true) + every { desc.source } returns "wpkh(@0)" + every { desc.keys } returns listOf(hdKey) + + val ur = mockk(relaxed = true) + every { ur.type } returns "output-descriptor" + every { ur.decodeFromRegistry() } returns desc + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.SEGWIT + } + + test("returns NESTED_SEGWIT when source starts with sh(wpkh(") { + val hdKey = minimalHdKeyMock() + val desc = mockk(relaxed = true) + every { desc.source } returns "sh(wpkh(@0))" + every { desc.keys } returns listOf(hdKey) + + val ur = mockk(relaxed = true) + every { ur.type } returns "output-descriptor" + every { ur.decodeFromRegistry() } returns desc + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.NESTED_SEGWIT + } + + test("returns LEGACY when source starts with pkh(") { + val hdKey = minimalHdKeyMock() + val desc = mockk(relaxed = true) + every { desc.source } returns "pkh(@0)" + every { desc.keys } returns listOf(hdKey) + + val ur = mockk(relaxed = true) + every { ur.type } returns "output-descriptor" + every { ur.decodeFromRegistry() } returns desc + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.LEGACY + } + + test("returns TAPROOT when source starts with tr(") { + val hdKey = minimalHdKeyMock() + val desc = mockk(relaxed = true) + every { desc.source } returns "tr(@0)" + every { desc.keys } returns listOf(hdKey) + + val ur = mockk(relaxed = true) + every { ur.type } returns "output-descriptor" + every { ur.decodeFromRegistry() } returns desc + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + result.detectedAddressType shouldBe AddressType.TAPROOT + } + + test("replaces @0 placeholder with key-origin string in keyMaterial") { + val hdKey = minimalHdKeyMock( + fingerprint = byteArrayOf(0xAB.toByte(), 0xCD.toByte(), 0xEF.toByte(), 0x01), + path = "84'/0'/0'" + ) + val desc = mockk(relaxed = true) + every { desc.source } returns "wpkh(@0)" + every { desc.keys } returns listOf(hdKey) + + val ur = mockk(relaxed = true) + every { ur.type } returns "output-descriptor" + every { ur.decodeFromRegistry() } returns desc + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldNotBeNull() + // @0 should have been replaced — the literal "@0" must not remain + (result.keyMaterial.contains("@0")) shouldBe false + } + + test("returns null when source is null") { + val desc = mockk(relaxed = true) + every { desc.source } returns null + every { desc.keys } returns emptyList() + + val ur = mockk(relaxed = true) + every { ur.type } returns "output-descriptor" + every { ur.decodeFromRegistry() } returns desc + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldBeNull() + } + } + + // ── parseUr — exception safety ──────────────────────────────────────────── + + context("parseUr - exception safety") { + + test("returns null instead of throwing when decodeFromRegistry throws") { + val ur = mockk(relaxed = true) + every { ur.type } returns "crypto-hdkey" + every { ur.decodeFromRegistry() } throws RuntimeException("CBOR decode failed") + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldBeNull() + } + + test("returns null instead of throwing for account-descriptor with null masterFingerprint") { + val account = mockk(relaxed = true) + every { account.masterFingerprint } returns null + + val ur = mockk(relaxed = true) + every { ur.type } returns "account-descriptor" + every { ur.decodeFromRegistry() } returns account + + val result = UrAccountParser.parseUr(ur, AddressType.SEGWIT) + result.shouldBeNull() + } + } +}) diff --git a/gradle.properties b/gradle.properties index 20e2a01..be1765c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,9 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +## Use LTS Java +org.gradle.java.home=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home +android.testOptions.unitTests.returnDefaultValues=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -20,4 +23,4 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67ddcc3..a0426da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,9 @@ okhttp = "4.12.0" biometric = "1.1.0" material = "1.12.0" hummingbird = "1.7.2" +kotest = "5.9.1" +mockk = "1.13.13" +coroutinesTest = "1.9.0" [libraries] @@ -45,6 +48,14 @@ androidx-biometric = { group = "androidx.biometric", name = "biometric", version google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } hummingbird = { group = "com.sparrowwallet", name = "hummingbird", version.ref = "hummingbird" } +# Testing +kotest-runner-junit5 = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" } +kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" } +kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotest" } +kotest-extensions-android = { group = "io.kotest.extensions", name = "kotest-extensions-android", version = "1.1.3" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755