Skip to content

feat: bump core to 0.3.9 and hw reliability#1062

Draft
piotr-iohk wants to merge 4 commits into
masterfrom
feat/wallet-scoped-core-0.3.9
Draft

feat: bump core to 0.3.9 and hw reliability#1062
piotr-iohk wants to merge 4 commits into
masterfrom
feat/wallet-scoped-core-0.3.9

Conversation

@piotr-iohk

@piotr-iohk piotr-iohk commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Part of #1030

This PR:

  1. Bumps bitkit-core from 0.1.75 to 0.3.9 and migrates CoreService, hardware wallet, activity, and migration paths to wallet-scoped APIs.
  2. Improves BLE Trezor reconnect reliability for Transfer to Spending after app restart or device sleep.
  3. Suppresses empty error toasts when the user cancels signing on the Trezor.
  4. Serializes transfer reconnect with in-flight auto-reconnect and scopes sign-screen warm-up to the selected hardware device.

Description

The bitkit-core upgrade brings wallet-scoped storage and Trezor stack improvements from the 0.3.x line (device-busy handling, typed session errors, transport callback updates). Android wiring moves default wallet identity through WalletScope, routes CoreService calls through scoped wallet ids, and updates hardware wallet, activity, migration, and dev Trezor tooling to match.

On top of the core bump, this branch hardens hardware transfer UX:

  • Transfer to Spending retries BLE reconnect with backoff instead of failing immediately when the Trezor is asleep after restart.
  • Opening Trezor Connect on the sign screen warms up that specific BLE device instead of triggering a global foreground reconnect for any known wallet.
  • Connect paths wait for an in-flight auto-reconnect before starting another attempt, reducing "connection already in progress" races.
  • User cancellation during signing (UserCancelled, PinCancelled, PassphraseCancelled) is treated as a silent no-op: no error toast, no stale-session disconnect churn.

Out of scope for this PR (still tracked elsewhere):

Preview

Before (master / 0.1.75) After (this branch)
before.mov
after.mov

Upload your local recordings into the table and below when opening the draft PR on GitHub.

QA Notes

Manual Tests

  • 1. Pair BLE Trezor → force-stop app → reopen → Savings with HW balance → Transfer to Spending → Open Trezor Connect: reconnect succeeds, PIN/sign flow completes (compare with before video on master).
  • 2. Transfer to Spending → sign screen open with BLE Trezor asleep: warm-up reconnect targets the transfer device without spurious toasts for unrelated wallets.
  • 3. Transfer to Spending → start sign → cancel on Trezor: no error toast; tapping Open Trezor Connect again resumes normally.
  • 4a. regression: Settings → connect known USB Trezor → get address / watch balance: still works after core bump.
    • 4b. regression: Settings → connect known BLE Trezor: pairing, PIN, and balance watch still work.
  • 5. regression: Upgrade from existing wallet (Room migration path): app opens, balances, activity, and hardware wallet tiles unchanged.
  • 6. regression: Normal Lightning send/receive and on-chain activity: unaffected by wallet-scoped core migration.

Automated Checks

  • Unit tests added: BLE reconnect retry and warm-up behavior in TrezorRepoTest.kt; Trezor cancel detection in TrezorExceptionExtTest.kt; cancel handling in TransferViewModelTest.kt and HwWalletRepoTest.kt.
  • Unit tests modified: wallet-scoped hardware wallet and activity assertions in HwWalletRepoTest.kt, ActivityRepoTest.kt, PreActivityMetadataRepoTest.kt, TrezorViewModelTest.kt, and related view model tests.
  • Local: just test file "to.bitkit.repositories.TrezorRepoTest", just test file "to.bitkit.repositories.HwWalletRepoTest", just test file "to.bitkit.viewmodels.TransferViewModelTest", just test file "to.bitkit.ext.TrezorExceptionExtTest".
  • Changelog fragment: add one user-facing fragment before publish (hw transfer reliability fixes).
  • CI: standard compile, unit test, and detekt checks run by the PR bot.

@piotr-iohk piotr-iohk marked this pull request as ready for review July 3, 2026 18:42
@piotr-iohk piotr-iohk marked this pull request as draft July 3, 2026 18:42

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detekt found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR bumps bitkit-core from 0.1.75 to 0.3.9, migrating all activity and hardware-wallet APIs to wallet-scoped identifiers via the new WalletScope and HwWalletId helpers. On top of the API migration, it hardens BLE Trezor reconnect UX with retry-with-backoff, targeted warm-up, in-flight serialisation, and silent user-cancellation handling.

  • Wallet-scoped API migration: WalletScope.default is injected everywhere walletId is now required (activities, pre-activity metadata, watcher params, migration); HwWalletId.derive replaces random UUID generation for hardware wallet IDs.
  • BLE reconnect reliability: reconnectKnownBluetoothDevice retries up to 4 times with growing delays; awaitInFlightConnect polls until any in-flight connect finishes before starting a competing attempt; warmUpKnownDevice pre-connects the specific transfer device when the sign screen opens.
  • User-cancellation suppression: isTrezorUserCancellation() walks the cause chain and short-circuits both ToastEventBus.send and AppViewModel.toast, preventing blank "Error" toasts when the user presses cancel on the Trezor during signing.

Confidence Score: 4/5

The BLE reconnect retry loop, in-flight serialisation, and user-cancellation suppression are all well-tested and logically sound; the wallet-scoped API migration is mechanical and comprehensive.

The double disconnectStaleSession call on the first BLE retry attempt and the broad exception swallowing in waitForConnectAttempt are style-level concerns unlikely to produce visible failures. The semantic shift in toMergedActivities from aggregation to last-writer-wins is intentional and test-confirmed but worth a second look. No correctness regressions were identified.

TrezorRepo.kt — the connectKnownDevice failure path and the waitForConnectAttempt polling loop; HwWalletRepo.toMergedActivities deduplication change.

Important Files Changed

Filename Overview
app/src/main/java/to/bitkit/repositories/TrezorRepo.kt Core BLE reconnect logic rewrite: adds reconnectKnownBluetoothDevice retry loop, awaitInFlightConnect polling, warmUpKnownDevice, and unconditional disconnectStaleSession on connect failure — one redundant disconnect when forceSession=true
app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt Removes manual HistoryTransaction-to-Activity conversion, delegates to core-provided Activity objects; deduplication semantics changed from aggregation to last-writer-wins by sorted address type; user-cancellation no longer triggers stale-session disconnect
app/src/main/java/to/bitkit/models/WalletScope.kt New singleton wrapping getDefaultWalletId() with a lazy initializer and a test override; eagerly consumed in ActivityService constructor — timing-safe as long as core is initialised before DI graph resolves ActivityService
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt Adds warmUpHardwareConnection delegation and user-cancellation silent-return in handleHardwareTransferFailure; BLE reconnect error now shows INFO toast instead of ERROR
app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt New extension walking the cause chain to detect any of the three user-cancellation exception types; well-tested
app/src/main/java/to/bitkit/services/CoreService.kt Mechanically routes every activity/pre-activity-metadata core call through walletId = WalletScope.default; no logic changes beyond the wallet-scoped API migration
app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt Guards send(Throwable) with isTrezorUserCancellation(); adds takeIf { it.isNotBlank() } guard against blank error messages
app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt Replaces HistoryTransaction list with Activity list in watcher state; derives a synthetic walletId for the dev watcher screen from the entered xpub
app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt Good new tests for warmUpKnownDevice and ensureConnected BLE retry; existing verify calls updated with the new walletId parameter
app/src/test/java/to/bitkit/test/BaseUnitTest.kt Adds @Before/@after hooks to set/clear WalletScope.testOverride for all unit tests — clean approach preventing lazy init from hitting native code in tests

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Screen as SpendingHwSignScreen
    participant TVM as TransferViewModel
    participant HWR as HwWalletRepo
    participant TR as TrezorRepo

    Screen->>TVM: warmUpHardwareConnection(deviceId)
    TVM->>HWR: warmUpKnownDevice(deviceId)
    HWR->>TR: warmUpKnownDevice(deviceId)
    TR->>TR: isKnownBluetoothDevice? / isConnectInProgress?
    TR->>TR: ensureConnected(deviceId) [fire and forget]

    Screen->>TVM: onTransferToSpendingHwConfirm(order, deviceId)
    TVM->>HWR: ensureConnected(deviceId)
    HWR->>TR: ensureConnected(deviceId)
    TR->>TR: connectedFeatures(deviceId)?

    alt already connected
        TR-->>HWR: Result.success(features)
    else in-flight connect
        TR->>TR: awaitInFlightConnect(deviceId)
        TR-->>HWR: Result.success(features) or retry
    else BLE retry loop
        loop up to 4 attempts backoff 2s 4s 6s
            TR->>TR: connectKnownDevice(deviceId)
        end
        TR-->>HWR: Result.success(features) or failure
    end

    HWR->>TR: signTxFromPsbt(...)
    alt UserCancelled PinCancelled PassphraseCancelled
        TR-->>HWR: Result.failure(TrezorException)
        HWR->>HWR: skip disconnectStaleSession
        HWR-->>TVM: Result.failure
        TVM->>TVM: isTrezorUserCancellation true silent return
    else signing success
        TR-->>HWR: Result.success(signedTx)
        HWR->>TR: broadcastRawTx
        TR-->>HWR: txId
        HWR-->>TVM: HwFundingBroadcastResult
        TVM->>Screen: TransferEffect.OnHwTxSigned
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Screen as SpendingHwSignScreen
    participant TVM as TransferViewModel
    participant HWR as HwWalletRepo
    participant TR as TrezorRepo

    Screen->>TVM: warmUpHardwareConnection(deviceId)
    TVM->>HWR: warmUpKnownDevice(deviceId)
    HWR->>TR: warmUpKnownDevice(deviceId)
    TR->>TR: isKnownBluetoothDevice? / isConnectInProgress?
    TR->>TR: ensureConnected(deviceId) [fire and forget]

    Screen->>TVM: onTransferToSpendingHwConfirm(order, deviceId)
    TVM->>HWR: ensureConnected(deviceId)
    HWR->>TR: ensureConnected(deviceId)
    TR->>TR: connectedFeatures(deviceId)?

    alt already connected
        TR-->>HWR: Result.success(features)
    else in-flight connect
        TR->>TR: awaitInFlightConnect(deviceId)
        TR-->>HWR: Result.success(features) or retry
    else BLE retry loop
        loop up to 4 attempts backoff 2s 4s 6s
            TR->>TR: connectKnownDevice(deviceId)
        end
        TR-->>HWR: Result.success(features) or failure
    end

    HWR->>TR: signTxFromPsbt(...)
    alt UserCancelled PinCancelled PassphraseCancelled
        TR-->>HWR: Result.failure(TrezorException)
        HWR->>HWR: skip disconnectStaleSession
        HWR-->>TVM: Result.failure
        TVM->>TVM: isTrezorUserCancellation true silent return
    else signing success
        TR-->>HWR: Result.success(signedTx)
        HWR->>TR: broadcastRawTx
        TR-->>HWR: txId
        HWR-->>TVM: HwFundingBroadcastResult
        TVM->>Screen: TransferEffect.OnHwTxSigned
    end
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt, line 283-290 (link)

    P2 Deduplication now picks last address-type alphabetically, not the aggregated value. Previously toMergedActivities summed received/sent across address types for the same txid. The new code sorts watchers by "$deviceId|$addressType" and overwrites the map on duplicate rawId(), so for a tx visible in both nativeSegwit and taproot, the taproot entry wins and the native-segwit value is silently dropped. The updated test confirms this intent (old 150uL, new 50uL). Worth confirming that the core library's Activity objects are already authoritative so no value is lost.

Reviews (1): Last reviewed commit: "fix: serialize hw reconnect and scope wa..." | Re-trigger Greptile

Comment on lines 680 to 684
}.onFailure { e ->
Logger.error("Connect known device failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
disconnectStaleSession(deviceId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double disconnectStaleSession on the first BLE retry attempt. When forceSession = true (attempt 0), connectKnownDevice already calls disconnectStaleSession at the start of the connection flow. If the connection then fails, the new onFailure block calls disconnectStaleSession a second time. Assuming the function is idempotent this is harmless, but it's redundant and could cause confusion if the implementation ever gains side effects. A simple guard makes the intent explicit.

Suggested change
}.onFailure { e ->
Logger.error("Connect known device failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
disconnectStaleSession(deviceId)
}
}.onFailure { e ->
Logger.error("Connect known device failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
if (!forceSession) {
// forceSession already disconnected before the attempt; avoid a redundant call.
disconnectStaleSession(deviceId)
}
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +747 to +759
private suspend fun waitForConnectAttempt(deviceId: String) {
runCatching {
withTimeout(CONNECT_ATTEMPT_MAX_WAIT) {
while (true) {
if (connectedFeatures(deviceId) != null) return@withTimeout
if (!isConnectInProgress()) return@withTimeout
delay(CONNECT_ATTEMPT_POLL_INTERVAL)
}
}
}.onFailure {
if (it is CancellationException && it !is TimeoutCancellationException) throw it
}
connectKnownDevice(deviceId, forceSession = false)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 waitForConnectAttempt silently swallows all non-CancellationException errors. Because connectedFeatures and isConnectInProgress are simple state reads this is unlikely to matter today, but limiting the swallowed set to TimeoutCancellationException only makes the contract clearer and avoids masking unexpected future failures.

Suggested change
private suspend fun waitForConnectAttempt(deviceId: String) {
runCatching {
withTimeout(CONNECT_ATTEMPT_MAX_WAIT) {
while (true) {
if (connectedFeatures(deviceId) != null) return@withTimeout
if (!isConnectInProgress()) return@withTimeout
delay(CONNECT_ATTEMPT_POLL_INTERVAL)
}
}
}.onFailure {
if (it is CancellationException && it !is TimeoutCancellationException) throw it
}
connectKnownDevice(deviceId, forceSession = false)
}
private suspend fun waitForConnectAttempt(deviceId: String) {
try {
withTimeout(CONNECT_ATTEMPT_MAX_WAIT) {
while (true) {
if (connectedFeatures(deviceId) != null) return@withTimeout
if (!isConnectInProgress()) return@withTimeout
delay(CONNECT_ATTEMPT_POLL_INTERVAL)
}
}
} catch (e: TimeoutCancellationException) {
// Expected: in-flight connect ran longer than our wait window; caller will retry.
}
}

Comment on lines +12 to +13

private val lazyDefault: String by lazy { getDefaultWalletId() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 lazyDefault is eagerly consumed in ActivityService's constructor. ActivityService stores private val walletId = WalletScope.default, triggering getDefaultWalletId() at construction time. Kotlin's SYNCHRONIZED lazy does not cache thrown exceptions, so a premature call would fail DI resolution rather than cache a wrong ID. Worth confirming that CoreService/ActivityService is never constructed before the core is initialised — tests use testOverride to sidestep this, but production relies on DI graph ordering.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3a41d26a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

addressType = addressType,
xpub = xpub,
electrumUrl = watcherSettings.electrumUrl,
walletId = device.walletId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate wallet ids before starting hardware watchers

For users upgrading with already-paired hardware wallets, KnownDevice.walletId deserializes as the new default "" until TrezorRepo.loadKnownDevices() migrates the store during startWatcher(). This spec is built from the raw HwWalletStore value before that migration, so the first watcher can be started with a blank wallet scope; when the migrated store emits, the watcher is considered active because only the Electrum URL is compared, so it is not restarted with the derived wallet id. That leaves upgraded devices running in the wrong/empty activity scope for the session; derive/fallback the id here or include walletId in the active watcher config and restart when it changes.

Useful? React with 👍 / 👎.

sortedBy { "${it.deviceId}|${it.addressType}" }
.flatMap { it.activities }
.forEach { activity ->
byId[activity.rawId()] = activity

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge duplicate watcher activities by amount

For transactions that touch two monitored xpubs for the same hardware wallet, such as receiving to both native SegWit and Taproot addresses, each address-type watcher can report an Activity.Onchain with the same txid and only that watcher's value. This assignment keeps whichever activity sorts last and drops the other value, so the wallet activity list undercounts while the balance still sums both watchers; keep the previous txid-level merge/summing behavior instead of overwriting by rawId().

Useful? React with 👍 / 👎.

allowBleFallback = allowBleFallback,
)
if (result.isSuccess) return result
lastFailure = result.exceptionOrNull()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop retrying BLE reconnect after user cancellation

When a BLE reconnect attempt fails because core returns a Trezor cancellation (for example the user cancels PIN, passphrase, or device approval), this loop treats it the same as a transient missing advertisement and continues through the remaining retry attempts. That can re-prompt the user several times after an explicit cancel before finally returning the failure; return cancellation failures immediately instead of storing them in lastFailure and continuing.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants