Skip to content

Split PR 578: BitBox connection lifecycle#607

Open
joshuakrueger-dfx wants to merge 15 commits into
RealUnitCH:stagingfrom
joshuakrueger-dfx:split/pr578-02-bitbox-lifecycle
Open

Split PR 578: BitBox connection lifecycle#607
joshuakrueger-dfx wants to merge 15 commits into
RealUnitCH:stagingfrom
joshuakrueger-dfx:split/pr578-02-bitbox-lifecycle

Conversation

@joshuakrueger-dfx
Copy link
Copy Markdown
Collaborator

Draft split from #578 after TaprootFreak review.

Scope:

  • ADR 0001 BitBox connection lifecycle
  • BitBox service/status lifecycle changes
  • connect-flow/home-bloc integration
  • focused lifecycle tests

Validation:

  • flutter pub get
  • dart run tool/generate_localization.dart
  • dart run tool/generate_release_info.dart
  • flutter pub run build_runner build --delete-conflicting-outputs
  • flutter analyze --no-pub
  • flutter test --reporter compact test/packages/hardware_wallet test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart test/screens/home/home_bloc_test.dart test/integration/bitbox_lifecycle_test.dart

@joshuakrueger-dfx joshuakrueger-dfx marked this pull request as ready for review May 29, 2026 08:34
@TaprootFreak TaprootFreak changed the base branch from develop to staging June 1, 2026 14:35
@joshuakrueger-dfx joshuakrueger-dfx force-pushed the split/pr578-02-bitbox-lifecycle branch from ae7a9d4 to 02d3b37 Compare June 1, 2026 14:42
Failing-on-purpose pin (commit-test-before-implementation per Mandate §3
Operating Loop). Asserts the Initiative I contract before the refactor
lands: Stream<BitboxConnectionStatus> as the sole source of truth, replay-
last semantics, init-concurrency property, state-machine traversal
property, dispose()/clear()/signalDeviceLost() lifecycle.

Test fails today with compile-time errors on missing API (clear, dispose,
status, currentStatus, signalDeviceLost) — exactly the surface ADR 0001
promises to add. The next commit refactors BitboxService and turns this
green.
…atus>

BitboxService now owns a broadcast Stream<BitboxConnectionStatus> with
replay-last-value semantics as the sole source of truth for the connect
state, per ADR 0001 (docs/adr/0001-bitbox-connection-lifecycle.md). The
`_isConnected` boolean is removed; consumers subscribe to `status` or
read `currentStatus` instead. `init()` is guarded by `_pendingInit` so
concurrent callers funnel onto a single bitboxManager.connect(). New
lifecycle methods land here as a coherent unit:

  - `init(BitboxDevice) -> Future<BitboxConnectionStatus>` — guarded by
    `_pendingInit`; emits `Connecting -> Paired` on success or
    `Connecting -> Disconnected` on failure.
  - `clear() -> Future<void>` — detaches credentials, tears down the
    observer, walks `Disconnecting -> Disconnected`, empties the
    credentials map. Idempotent.
  - `signalDeviceLost(LostReason)` — emits `Lost(reason)` from Paired /
    InUse only; tears down the observer; idempotent from non-live states.
  - `dispose()` — emits a final `Disconnected`, closes the controller,
    rejects subsequent `init()` with StateError. Idempotent.

The replay-last-value getter installs the upstream subscription BEFORE
delivering the cached value so any `_emit` racing with `service.status.listen`
lands in the broadcast pending queue rather than being silently dropped
(an async* `yield initial; yield* upstream;` would have surfaced this as
a lost Lost emission between observe + signalDeviceLost in fakeAsync).

BitboxCredentials gains an optional `_onSignQueueTimeout` callback so the
sign-queue timeout can later propagate Lost(signQueueTimeout) to the
service via a uni-directional closure (call-site lands in the propagation
commit so the wiring change is reviewable on its own).

The existing service test suite is migrated to the new return-type
contract:
  - `init().catchError(...)` returns a typed BitboxConnectionStatus.
  - F-007 concurrent-init pin tightens from `<=2 opens` to exactly `1`
    (post-`_pendingInit` invariant).
  - F-033 dispose pin flips from absence-check to a real lifecycle assertion
    (final Disconnected + StateError on post-dispose init).
Extends the lifecycle suite with multi-subscriber + cancel-leak guards
and three observable post-conditions of clear():

  - two simultaneous subscribers observe identical traversals (broadcast
    contract);
  - cancelled subscriptions stop accruing events (close-leak guard for
    cubit close()-paths);
  - clear() drops cached credentials (re-init starts fresh);
  - clear() nulls the BitboxManager on every credentials in the map;
  - currentStatus is Disconnected after clear() completes.

These pin the post-conditions ADR 0001 enumerates so a refactor of the
Stream wiring (e.g. dropping the replay-last semantics in favour of
plain broadcast) trips immediately.
On a TimeoutException inside `_synchronizeBoundedSign`, BitboxCredentials
now also calls the service-wired `_onSignQueueTimeout` closure so the
service-level Stream transitions to `Lost(signQueueTimeout)`. Before this
change the timeout cleared local credentials but left BitboxService still
reporting Paired; the observer kept polling, and the consuming cubit had
no way to learn the device was lost without polling currentStatus.

The closure is wired one-way (service → credentials) in
`BitboxService.getCredentials` so credentials never reach back through a
singleton getter — closes F-009 without introducing a circular import or a
service-level static.
Five new tests cover the wire from sign-queue timeout to the
service-level signalDeviceLost callback:

  - hung sign fires the callback exactly once AND clears credentials;
  - successful sign does NOT invoke the callback (negative pin);
  - native-error path (non-timeout exception) does NOT invoke the
    callback — only the timeout flips the service to Lost;
  - omitting the callback parameter keeps the timeout path safe (no NPE);
  - property over K in [1..6] signs: after a mid-batch timeout, callback
    fires exactly once and every subsequent sign fails fast at the
    snapshot null-check without ever reaching the native mock.

Together with the lifecycle suite these close F-009: the sign-queue
timeout can no longer silently desync BitboxService from the local
credentials state.
`_onDeleteCurrentWallet` now invokes `BitboxService.clear()` in addition
to the existing `stopConnectionStatusObserver` call. Per ADR 0001 the
clear walks the lifecycle Stream to Disconnected, empties the
credentials map, and disconnects the BitboxManager — closing F-024 so a
subsequent "restore different seed, re-pair the same device" can no
longer silently re-attach the old derivation path against the device's
new static pubkey. Two new home_bloc tests pin the call (with and
without an existing wallet).
ConnectBitboxCubit now subscribes to the service-owned lifecycle Stream
in its constructor. A service-emitted Lost (sign-queue timeout, observer
device-vanish, future static-pubkey mismatch) routes the cubit back to
BitboxNotConnected and re-arms the scan timer without forcing the
existing try/catch blocks to also poll currentStatus. The subscription
is cancelled in `close()` so the broadcast Stream stops holding a
reference to the closed cubit — matches the subscription-leak guard in
bitbox_service_lifecycle_test.dart.

`_pendingInit` is retyped to `Future<BitboxConnectionStatus>` and the
confirmPairing path checks for `Paired`/`InUse` instead of a Boolean.
The previous `bool isConnected`-like flag was already absent from the
cubit (only the service held it); this change finishes the conversion by
making the cubit a consumer of the stream rather than a polling caller.

Five new tests cover:
  - construction registers a single status subscription;
  - mid-flow Lost bounces an in-progress pairing back to NotConnected;
  - non-Lost transitions on the stream do NOT perturb the cubit state;
  - close() cancels the subscription (no listener leak);
  - Lost emitted after close() is ignored without throwing.
End-to-end Tier-1 suite for ADR 0001's state machine. No mocks above the
service surface: real BitboxService, real BitboxCredentials, real broadcast
Stream<BitboxConnectionStatus>. The simulated platform from
bitbox_flutter is the only seam.

Covers:
  - happy path: init → pair → sign → clear (full lifecycle round-trip);
  - disconnect-mid-sign: observer flips service to Lost(deviceUnreachable);
  - reconnect after Lost: clear() resets the map and a fresh init() heals
    the new credentials;
  - sign-queue timeout: signalDeviceLost(signQueueTimeout) surfaces Lost
    on the stream before BitboxNotConnectedException reaches the caller;
  - 3-cycle stress (pair → sign → clear repeated): no observer-timer leak,
    sign count matches cycle count exactly;
  - signalDeviceLost from Disconnected is a no-op (no spurious Lost);
  - sign on cleared service fast-fails with BitboxNotConnectedException;
  - dispose() closes the stream and rejects subsequent init().

PR RealUnitCH#468's 17-item tracking issue gets the Tier-1 conformance pin it
needed.
…ls.dart

Drives the remaining surface to 100% line coverage on both files:

  - integration lifecycle: startScan delegation, connect-throw catch arm
    (Connecting → Disconnected), getChannelHash + confirmPairing
    delegation, !didVerify branch on confirmPairing, end-to-end pin of
    `_onCredentialsSignQueueTimeout` (the closure wired in
    `BitboxService.getCredentials` actually routes a hung credentials
    sign through `signalDeviceLost(signQueueTimeout)` — fakeAsync drives
    the 5-minute queue-bound to virtual time);
  - credentials defensive pins: the pre-existing `address` getter,
    `signToEcSignature` and `signPersonalMessageToUint8List`
    UnimplementedError throws, and the >32-bit chainId truncation loop in
    `signToSignature`.

bitbox.dart: 91.7 → 100% line coverage
bitbox_credentials.dart: 90.5 → 100% line coverage
@TaprootFreak TaprootFreak force-pushed the split/pr578-02-bitbox-lifecycle branch from 02d3b37 to f3ffa0a Compare June 2, 2026 08:25
@TaprootFreak TaprootFreak added the tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR label Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants