Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ jobs:
cache: true
cache-dependency-path: go/go.sum
- run: go vet ./...
- run: go test -race -timeout 60s ./...
# -count=1 disables the test cache. The source assertions in
# ios_bluetooth_regression_test.go read Swift and Kotlin files, which live
# outside the module root, so cmd/go does not hash them into the cache key
# — a PR that only touches those files would otherwise be served a stale
# "ok" and the guards would never run.
- run: go test -race -timeout 60s -count=1 ./...

yaml-lint:
name: Workflow YAML lint
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
## 0.0.11

* Expose `BitboxManager.getFirmwareVersion()`, returning the connected device's
main firmware version `v`-prefixed (e.g. `v9.26.4`). It is readable once the
pairing has been established, on both transports, and costs no device
round-trip afterwards. Null means the version is not known — before
`initBitBox`, after a pairing the device declined (which `initBitBox()` still
reports as true, see below), after `close`, or when the device reported a
version that could not be parsed — and never "old firmware", so a host gating
on a minimum version must treat the two apart and refuse rather than pass
when it is absent.
* Disconnecting now also releases the device inside the native binding — on an
explicit `close()`, on a peripheral that drops on its own, and when connecting
to another device without closing first. Previously the binding kept the
previous device, so `getDeviceStatus()` (and the new `getFirmwareVersion()`)
could answer for a device that was no longer attached.
* `supportsETH()` / `supportsERC20()` are derived from the firmware version, so
they now report no support whenever that version is unknown: before the
pairing is established, after one the device declined, and when the device
reported a version string the binding could not parse (where it previously
answered from an internal placeholder). Both track `getFirmwareVersion()`
returning null.
* Over Bluetooth the version is known before pairing, but `getFirmwareVersion()`
withholds it until the pairing is established — a declined or failed pairing
must not vouch for a channel that was never established. (The native binding
also withholds it when the host rejects the code; `BitboxManager` never does,
since `channelHashVerify()` always affirms.) Note the SDK reports a decline by
leaving the channel hash unverified rather than by returning an error, so
`initBitBox()` still resolves true there; only the version and the
capabilities derived from it are withheld.
* Testkit: `SimulatedBitboxPlatform.supportsLTC()`, `supportsETH()` and
`supportsERC20()` now report false until the simulated pairing is established
— `initBitBox()` returning true is not enough, see the new `pairingVerified`
knob. A consumer test that asserted any of the three straight after
`connect()` needs an `initBitBox()` first.

## 0.0.10

* Expose `BitboxManager.getDeviceStatus()`, returning the SDK's cached firmware
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ CI runs three jobs on every PR and every push to `develop` / `main` (`.github/wo

| Job | What it does |
|---|---|
| `Flutter analyze + test` | `dart format --set-exit-if-changed`, `flutter analyze --no-fatal-infos`, `flutter test` |
| `Go unit tests` | `go vet ./...`, `go test -race -timeout 60s ./...` against `go/api` and `go/u2fhid` |
| `Flutter analyze + test` | `dart format --set-exit-if-changed`, `flutter analyze --fatal-infos`, `flutter test` |
| `Go unit tests` | `go vet ./...`, `go test -race -timeout 60s -count=1 ./...` against `go/api` and `go/u2fhid` |
| `Workflow YAML lint` | `yaml.safe_load` on every `.github/**/*.y*ml` |

Run the same gate locally — see [TESTING.md → Fast PR gate](TESTING.md#fast-pr-gate). Lint failures upstream are wasted CI minutes; catch them locally.

## Adding a new platform method

1. **Dart side**: declare the abstract method on `BitboxUsbPlatform` (`lib/usb/bitbox_usb_platform_interface.dart`) and implement it on `BitboxUsbMethodChannel` (`lib/usb/bitbox_usb_method_channel.dart`).
1. **Dart side**: declare the abstract method on `BitboxUsbPlatform` (`lib/usb/bitbox_usb_platform_interface.dart`) and implement it on `MethodChannelBitboxUsb` (`lib/usb/bitbox_usb_method_channel.dart`).
2. **Go side** (gomobile-exported): add the corresponding function in `go/api/*.go`, wrapped with `defer recoverPanic("<name>")` so a Go-side crash returns a zero value instead of taking the engine down.
3. **Native bridges**: wire the new method through `android/src/main/kotlin/.../MethodCallRegistry.kt` and the iOS handler in `ios/Classes/BitboxFlutterPlugin.swift`.
4. **Testkit**: implement the method on `SimulatedBitboxPlatform` in `lib/testing/bitbox_testkit.dart` so consumer apps can exercise it without hardware. Add a method-name constant to `SimulatedBitboxMethod`.
Expand Down
44 changes: 43 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ flutter test
# Go (from go/)
cd go
go vet ./...
go test -race -timeout 60s ./...
go test -race -timeout 60s -count=1 ./...
```

The Go API tests include a generic fake BitBox device. It is not
Expand Down Expand Up @@ -89,6 +89,48 @@ The tests explicitly guard against these hardware-wallet regressions:
- iOS BLE read timeout regressing from 60 seconds to 10 seconds
- U2FHID assumptions drifting away from the iOS BLE bridge contract
- Pairing/channel-hash behavior not being simulatable without hardware
- An unknown firmware version being conflated with an old one. The SDK panics
when `Version()` is read before the device reports one; the pairing gate
already answers `""` in that window, and `recoverPanic` backstops the panic
should it ever be reached — the Go test drives that path directly. The Dart
side maps `""` to null, and the testkit withholds the version until the
simulated pairing is established — `initBitBox()` returning true is not
enough, see its `pairingVerified` knob — so a consumer's version gate cannot
pass its tests and then read null on hardware.
- A version answering for a pairing that never completed. Bluetooth knows the
version before `initBitBox`, so the binding tracks whether the pairing was
actually established and withholds the version — and the capabilities derived
from it — until then. The signal is the channel hash being device-verified and
not since repudiated by the host, NOT `Init()` returning without error: the
SDK returns nil on a decline, having already dropped both ciphers. Covered on
the Go side for a decline, a host affirming a decline, a host-rejected code
and its re-affirm, a failed init, a failed re-init, and an init that lands
after the device was replaced.
- A device that is gone still answering. On iOS both `handleDisconnect` and
`connect(to:)` call `ReleaseDevice`, since nothing rebinds the Go side until
`initBitBox` — so a peripheral that drops on its own, and one that is replaced
without closing first, both clear the binding. Android releases in
`CloseOperation` and, before the rebind, in `ConnectBitBoxOperation`. All four
are pinned from CI by source assertions on `Bluetooth.swift`,
`BitboxFlutterPlugin.swift` and the two Kotlin operations, the same way the
60s read timeout is, plus Go and testkit coverage. Those assertions scope to
the enclosing function, ignore commented-out calls, check the
release-before-rebind ordering on the connect path, and fail loudly if they
can no longer find the function rather than degrading into a file-wide search.
- The placeholder version `GetDeviceWithInfo` substitutes when the device's own
version string does not parse escaping as if it were the device's. It is
withheld from `FirmwareVersion` *and* from `SupportsETH` / `SupportsERC20`,
which the SDK derives from the version — so unparseable reads as unknown
everywhere rather than as a specific wrong number a gate would act on. The
testkit applies the same rule to its configured version, so a consumer's
capability gate does not pass against the simulator and read false on
hardware. A behaviour installed with `when` moves only the method it targets.
- `bitbox`, its synthetic-version flag and its initialised flag being written
without synchronisation. They are replaced as a set under `deviceMu`, and
every export takes a single snapshot, because close/disconnect runs on a
different thread than an in-flight signature or pairing handshake. A dedicated
test drives readers and writers concurrently, so `go test -race` fails if the
lock is removed.
- ETH/BTC success, error, and panic flows not being simulatable without hardware
- App-level Flutter flows not being testable with deterministic BitBox delays
and aborts
Binary file modified android/libs/api-sources.jar
Binary file not shown.
Binary file modified android/libs/api.aar
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.cakewallet.bitbox_flutter.operations.ETHSignTransactionOperation
import com.cakewallet.bitbox_flutter.operations.ETHSignTypedMessageOperation
import com.cakewallet.bitbox_flutter.operations.GetChannelHashOperation
import com.cakewallet.bitbox_flutter.operations.GetDeviceStatusOperation
import com.cakewallet.bitbox_flutter.operations.GetFirmwareVersionOperation
import com.cakewallet.bitbox_flutter.operations.GetDevicesOperation
import com.cakewallet.bitbox_flutter.operations.ETHGetAddressOperation
import com.cakewallet.bitbox_flutter.operations.ETHSignRLPTransactionOperation
Expand Down Expand Up @@ -55,6 +56,7 @@ class BitboxFlutterPlugin : FlutterPlugin, MethodCallHandler {
registry.registerMethodCall("getChannelHash", GetChannelHashOperation(bitboxManager))
registry.registerMethodCall("channelHashVerify", ChannelHashVerifyOperation(bitboxManager))
registry.registerMethodCall("getDeviceStatus", GetDeviceStatusOperation(bitboxManager))
registry.registerMethodCall("getFirmwareVersion", GetFirmwareVersionOperation(bitboxManager))
registry.registerMethodCall("getMasterFingerprint", GetMasterFingerprintOperation(bitboxManager))
registry.registerMethodCall("supportsETH", SupportsETHOperation(bitboxManager))
registry.registerMethodCall("supportsERC20", SupportsERC20Operation(bitboxManager))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.cakewallet.bitbox_flutter.operations

import android.content.Context
import api.Api
import com.cakewallet.bitbox_flutter.BitboxManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
Expand All @@ -18,6 +19,11 @@ class CloseOperation(private val manager: BitboxManager) :
manager.gracefullyReset()
}

// Drop the Go-side device too, so the next connection is not answered
// with this one's cached status and firmware version. Runs after the
// reset path as well, which is exactly when the stale state matters.
Api.releaseDevice()

result.success(true)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.cakewallet.bitbox_flutter.operations

import android.content.Context
import api.Api
import com.cakewallet.bitbox_flutter.BitBoxException
import com.cakewallet.bitbox_flutter.BitboxManager
import io.flutter.plugin.common.MethodCall
Expand All @@ -13,6 +14,11 @@ class ConnectBitBoxOperation(private val manager: BitboxManager) :
methodCall: MethodCall,
result: MethodChannel.Result
) {
// Opening starts a new device. Release first so a failed open cannot
// leave the previous one answering: the success path rebinds
// immediately via Api.getDevice, but the error path never would.
Api.releaseDevice()

val identifier: String? = methodCall.argument("identifier")
try {
this.manager.connectBitBox(identifier!!)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.cakewallet.bitbox_flutter.operations

import android.content.Context
import api.Api
import com.cakewallet.bitbox_flutter.BitboxManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

class GetFirmwareVersionOperation(manager: BitboxManager) : UsbMethodCallOperation(manager.usbManager) {
override fun onMethodCall(
context: Context,
methodCall: MethodCall,
result: MethodChannel.Result
) {
// Api.firmwareVersion() reads the version the SDK already holds — no
// device round-trip — so it is safe on the serial queue like
// getDeviceStatus. It stays empty until the pairing is established —
// initBitBox returning true is not enough.
val version = Api.firmwareVersion()
result.success(version)
}
}
Loading
Loading