Summary
Every failure mode of signETHTypedMessage — firmware rejection, transport error, malformed request, genuine user cancellation — is collapsed into an empty byte array, so callers can only report "the user cancelled". The real error is printed to stdout and then discarded.
The chain
1. Go — error text discarded (go/api/ethereum.go:201-206)
signature, err = bitbox.ETHSignTypedMessage(uint64(chainId), keypathData, jsonMsg)
if err != nil {
fmt.Printf("[ETHSignTypedMessage] device error: %v\n", err)
return nil
}
2. Swift — nil becomes empty Data() (ios/Classes/BitboxFlutterPlugin.swift:299-306)
let signature = ApiETHSignTypedMessage(chainId, keypathHex, jsonMessage.data)
DispatchQueue.main.async {
if let sig = signature {
result(FlutterStandardTypedData(bytes: sig))
} else {
result(FlutterStandardTypedData(bytes: Data()))
}
}
3. Dart — empty passed through (lib/usb/bitbox_usb_method_channel.dart)
return result ?? Uint8List(0);
Consumers then have no signal other than "empty", and the only reasonable interpretation left to them is user cancellation.
The same pattern applies to signETHMessage (go/api/ethereum.go:185-187) and to the other nil → Data() branches in the plugin.
Impact
A real device error is indistinguishable from a user pressing cancel. Diagnosing an actual firmware rejection required attaching to the app's stdout with devicectl device process launch --console to recover the one line that was already being thrown away:
[ETHSignTypedMessage] device error: unexpected NACK response
Without that, the only visible symptom was a "signature cancelled, please confirm on the device again" message, which is actively misleading — the user had confirmed, and retrying could never succeed.
Suggested fix
Propagate the error instead of flattening it:
- Go: return the error text alongside the signature (or expose a last-error accessor) rather than
return nil
- Swift:
result(FlutterError(code:message:details:)) on failure instead of empty Data()
- Dart: throw a typed exception carrying the device message; keep a distinct type for genuine user abort so callers can still special-case it
bitbox02-api-go already distinguishes the two — firmware.IsErrorAbort(err) identifies a real user abort (code 104) versus any other device or transport error — so the information needed to keep the cancellation case separate is available at the Go layer.
Summary
Every failure mode of
signETHTypedMessage— firmware rejection, transport error, malformed request, genuine user cancellation — is collapsed into an empty byte array, so callers can only report "the user cancelled". The real error is printed to stdout and then discarded.The chain
1. Go — error text discarded (
go/api/ethereum.go:201-206)2. Swift —
nilbecomes emptyData()(ios/Classes/BitboxFlutterPlugin.swift:299-306)3. Dart — empty passed through (
lib/usb/bitbox_usb_method_channel.dart)Consumers then have no signal other than "empty", and the only reasonable interpretation left to them is user cancellation.
The same pattern applies to
signETHMessage(go/api/ethereum.go:185-187) and to the othernil→Data()branches in the plugin.Impact
A real device error is indistinguishable from a user pressing cancel. Diagnosing an actual firmware rejection required attaching to the app's stdout with
devicectl device process launch --consoleto recover the one line that was already being thrown away:Without that, the only visible symptom was a "signature cancelled, please confirm on the device again" message, which is actively misleading — the user had confirmed, and retrying could never succeed.
Suggested fix
Propagate the error instead of flattening it:
return nilresult(FlutterError(code:message:details:))on failure instead of emptyData()bitbox02-api-goalready distinguishes the two —firmware.IsErrorAbort(err)identifies a real user abort (code104) versus any other device or transport error — so the information needed to keep the cancellation case separate is available at the Go layer.