Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8db4de7
fix(ios): exclude wallet data from backups and drop dead bg tasks
ethicnology Aug 12, 2026
5b94f7a
fix(android): remove the unhandled exported bitcoin: intent filter
ethicnology Aug 12, 2026
ef78955
fix(qr): harden BBQR and UR frame handling against wedged scans
ethicnology Aug 12, 2026
33a1a4a
fix(bip85): bind derivations to their source seed and harden screens
ethicnology Aug 12, 2026
b19761e
fix(bitbox): script-aware xpubs, typed error map, fair BLE settle
ethicnology Aug 12, 2026
fc89be5
fix(fees): bound oracle values, add timeouts, block redirects and Tor…
ethicnology Aug 12, 2026
4b0a00f
fix(mempool): harden custom server URLs, validation and storage
ethicnology Aug 12, 2026
6785ac4
fix(log): scrub secrets from TSV logs and minimize Sentry payloads
ethicnology Aug 12, 2026
9105825
fix(import): reject private keys, hidden mismatches and orphaned seeds
ethicnology Aug 12, 2026
dcc03b8
fix(labels): harden BIP329 import against keys, freezes and forgery
ethicnology Aug 12, 2026
15fe014
fix(swaps): bind pending swaps to wallets and anchor payment parsing
ethicnology Aug 12, 2026
08620df
fix(send): invalidate stale signatures and harden the review flow
ethicnology Aug 12, 2026
47fcfab
fix(broadcast): verify parents, checksums and finalize signed PSBTs
ethicnology Aug 12, 2026
225e086
fix(transactions): verify server data before labels, binding and export
ethicnology Aug 12, 2026
b8f9ba3
test(bitbox): document the upstream bridge findings
ethicnology Aug 12, 2026
8b9f2c2
ci: install FVM from the canonical release repository
ethicnology Aug 12, 2026
ff6d9ce
test(send): align LNAddress parsing expectation
ethicnology Aug 13, 2026
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
23 changes: 22 additions & 1 deletion .github/actions/flutter-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,28 @@ runs:
- name: Install FVM
shell: bash
run: |
curl -fsSL https://fvm.app/install.sh | bash -s -- 4.1.2
case "$(uname -m)" in
x86_64) fvm_arch=x64 ;;
aarch64|arm64) fvm_arch=arm64 ;;
*) echo "Unsupported FVM architecture: $(uname -m)" >&2; exit 1 ;;
esac
archive="$RUNNER_TEMP/fvm-4.1.2-linux-${fvm_arch}.tar.gz"
curl --retry 5 --retry-all-errors -fL \
"https://github.com/conceptadev/fvm/releases/download/4.1.2/fvm-4.1.2-linux-${fvm_arch}.tar.gz" \
-o "$archive"
mkdir -p "$HOME/fvm/bin"
# Extract the whole tree, not just `fvm/fvm`: the two release archives
# have different layouts. x64 ships a self-contained binary, arm64 a
# launcher script that execs `src/dart` relative to its own directory —
# unpacking the launcher alone yields `exec: .../src/dart: not found`
# on every fvm invocation.
tar -xzf "$archive" -C "$HOME/fvm/bin" --strip-components=1
chmod +x "$HOME/fvm/bin/fvm"
if [ -f "$HOME/fvm/bin/src/dart" ]; then
chmod +x "$HOME/fvm/bin/src/dart"
fi
# Prove the launcher actually runs before anything depends on it.
"$HOME/fvm/bin/fvm" --version
echo "$HOME/fvm/bin" >> $GITHUB_PATH

- name: Check Flutter version
Expand Down
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ graph TB
BUY --> EXCHANGE
BUY --> RECEIVE
BUY --> BULL_PAYJOIN
BUY --> TX_HISTORY
COINS --> UTXO_MGMT
COINS --> LABELS
COINS --> WALLETS
Expand All @@ -94,6 +95,7 @@ graph TB
SECRETS --> CORE
SELL --> EXCHANGE
SELL --> BULL_PAYJOIN
SELL --> TX_HISTORY
SEND --> CONSOLIDATION
SEND --> FEES
SEND --> NETWORK
Expand Down
6 changes: 0 additions & 6 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,6 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitcoin" />
</intent-filter>
<!-- USB device attached intent filter for BitBox02 -->
<intent-filter>
<action
Expand Down
2 changes: 2 additions & 0 deletions integration_test/bip85_derivation_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:bb_mobile/core/bip85/data/bip85_datasource.dart';
import 'package:bb_mobile/core/bip85/data/bip85_repository.dart';
import 'package:bb_mobile/core/bip85/domain/derive_next_bip85_mnemonic_from_default_wallet_usecase.dart';
import 'package:bb_mobile/core/seed/data/repository/seed_repository.dart';
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
import 'package:bb_mobile/core/storage/sqlite_database.dart';
import 'package:bb_mobile/core/storage/tables/bip85_derivations_table.dart';
import 'package:bb_mobile/core/utils/bip32_derivation.dart';
Expand Down Expand Up @@ -50,6 +51,7 @@ Future<void> main({bool isInitialized = false}) async {
bip85Repository: bip85Repository,
walletRepository: walletRepository,
seedRepository: seedRepository,
settingsRepository: locator<SettingsRepository>(),
);

setUpAll(() async {
Expand Down
34 changes: 17 additions & 17 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import workmanager_apple
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
excludeSensitiveFilesFromBackup()

// workmanager_apple spawns a separate FlutterEngine per background task
// (see BackgroundWorker.swift in workmanager_apple). Plugins registered
Expand All @@ -23,23 +24,22 @@ import workmanager_apple
GeneratedPluginRegistrant.register(with: registry)
}

WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "com.bullbitcoin.mobile.bitcoin-sync-id",
frequency: NSNumber(value: 20 * 60)
)
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "com.bullbitcoin.mobile.liquid-sync-id",
frequency: NSNumber(value: 20 * 60)
)
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "com.bullbitcoin.mobile.swaps-sync-id",
frequency: NSNumber(value: 20 * 60)
)
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "com.bullbitcoin.mobile.logs-prune-id",
frequency: NSNumber(value: 20 * 60)
)

return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

private func excludeSensitiveFilesFromBackup() {
let fileManager = FileManager.default
guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
return
}

// Wallet, payjoin, wallet-engine, and TSV log data all live below Documents.
// Excluding the directory also covers wallet directories created after startup.
var documentsURL = documentsDirectory
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
// `setResourceValues` is `mutating`, so it needs a `var` receiver.
// URLResourceValues.isExcludedFromBackup sets NSURLIsExcludedFromBackupKey.
try? documentsURL.setResourceValues(resourceValues)
}
}
24 changes: 18 additions & 6 deletions lib/core/bbqr/bbqr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,25 @@ class Bbqr {
}
} else {
final scannedOptions = BbqrOptions.decode(payload);
if (options != null && scannedOptions.total != options!.total) {
// reset another state.bbqr
// and expect the next scan to be a new BBQR
parts.clear();
return (null, this);
if (options != null &&
(scannedOptions.total != options!.total ||
scannedOptions.encoding != options!.encoding ||
scannedOptions.type != options!.type)) {
_reset();
}

options = scannedOptions;
parts[options!.share] = payload;

if (options!.total == parts.length) {
final bbqrParts = parts.values.toList();
final bbqrJoiner = await bbqr.Joined.tryFromParts(parts: bbqrParts);
late final bbqr.Joined bbqrJoiner;
try {
bbqrJoiner = await bbqr.Joined.tryFromParts(parts: bbqrParts);
} catch (_) {
_reset();
throw FailedToParseBbqr();
}

try {
final tx = await BitcoinTx.fromBytes(bbqrJoiner.data);
Expand All @@ -79,13 +85,19 @@ class Bbqr {
);
} catch (_) {}

_reset();
throw FailedToParseBbqr();
} else {
return (null, this);
}
}
}

void _reset() {
parts.clear();
options = null;
}

static Future<List<String>> splitPsbt(String psbt) async {
try {
// check if the PSBT is valid, will throw if not
Expand Down
6 changes: 5 additions & 1 deletion lib/core/bbqr/bbqr_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@ class BbqrOptions {
}

factory BbqrOptions.decode(String code) {
if (code.length < 6) throw "Encoded string is too short";
if (code.length < 8) throw "Encoded string is too short";
if (code.substring(0, 2) != "B\$") throw "Invalid header: expected 'B\$'";

final totalQRCodes = int.parse(code.substring(4, 6), radix: 36);
final sequenceNumber = int.parse(code.substring(6, 8), radix: 36);

if (totalQRCodes < 1 || sequenceNumber >= totalQRCodes) {
throw "Invalid BBQR sequence";
}

return BbqrOptions(
encoding: code[2],
type: code[3],
Expand Down
3 changes: 3 additions & 0 deletions lib/core/bip85/bip85_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:bb_mobile/core/bip85/domain/derive_next_bip85_hex_from_default_w
import 'package:bb_mobile/core/bip85/domain/derive_next_bip85_mnemonic_from_default_wallet_usecase.dart';
import 'package:bb_mobile/core/bip85/domain/revoke_bip85_derivation_usecase.dart';
import 'package:bb_mobile/core/seed/data/repository/seed_repository.dart';
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
import 'package:bb_mobile/core/storage/sqlite_database.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:get_it/get_it.dart';
Expand All @@ -29,6 +30,7 @@ class Bip85DerivationsLocator {
bip85Repository: locator<Bip85Repository>(),
walletRepository: locator<WalletRepository>(),
seedRepository: locator<SeedRepository>(),
settingsRepository: locator<SettingsRepository>(),
),
);

Expand All @@ -37,6 +39,7 @@ class Bip85DerivationsLocator {
bip85Repository: locator<Bip85Repository>(),
walletRepository: locator<WalletRepository>(),
seedRepository: locator<SeedRepository>(),
settingsRepository: locator<SettingsRepository>(),
),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:meta/meta.dart';
import 'package:bb_mobile/core/settings/data/settings_repository.dart';

class DeriveNextBip85HexFromDefaultWalletUsecase {
final Bip85Repository _bip85Repository;
final WalletRepository _walletRepository;
final SeedRepository _seedRepository;
final SettingsRepository _settingsRepository;

DeriveNextBip85HexFromDefaultWalletUsecase({
required this._bip85Repository,
required this._walletRepository,
required this._seedRepository,
required this._settingsRepository,
});

@useResult
Expand All @@ -25,9 +28,13 @@ class DeriveNextBip85HexFromDefaultWalletUsecase {
String? alias,
}) async {
try {
// Derive from the default wallet of the environment the app is actually
// running in: a hardcoded mainnet lookup finds no wallet on testnet.
final settings = await _settingsRepository.fetch();
final wallets = await _walletRepository.getWallets(
onlyDefaults: true,
onlyBitcoin: true,
environment: settings.environment,
);
if (wallets.isEmpty) return const Err(Bip85NoDefaultWalletFailure());
final defaultWallet = wallets.first;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bip39_mnemonic/bip39_mnemonic.dart' as bip39;
import 'package:meta/meta.dart';
import 'package:bb_mobile/core/settings/data/settings_repository.dart';

class DeriveNextBip85MnemonicFromDefaultWalletUsecase {
final Bip85Repository _bip85Repository;
final WalletRepository _walletRepository;
final SeedRepository _seedRepository;
final SettingsRepository _settingsRepository;

DeriveNextBip85MnemonicFromDefaultWalletUsecase({
required this._bip85Repository,
required this._walletRepository,
required this._seedRepository,
required this._settingsRepository,
});

@useResult
Expand All @@ -27,9 +30,13 @@ class DeriveNextBip85MnemonicFromDefaultWalletUsecase {
String? alias,
}) async {
try {
// Derive from the default wallet of the environment the app is actually
// running in: a hardcoded mainnet lookup finds no wallet on testnet.
final settings = await _settingsRepository.fetch();
final wallets = await _walletRepository.getWallets(
onlyDefaults: true,
onlyBitcoin: true,
environment: settings.environment,
);
if (wallets.isEmpty) return const Err(Bip85NoDefaultWalletFailure());
final defaultWallet = wallets.first;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bip85_entropy/bip85_entropy.dart' as bip85;
import 'package:meta/meta.dart';
import 'package:convert/convert.dart';
import 'package:bip32_keys/bip32_keys.dart' as bip32;

class FetchAllBip85DerivationsWithEntropyUsecase {
final Bip85Repository _bip85Repository;
Expand Down Expand Up @@ -37,13 +39,23 @@ class FetchAllBip85DerivationsWithEntropyUsecase {
case Err(:final failure):
return Err(failure);
case Ok(:final value):
final derivationsWithEntropy = value.map((e) {
final entropy = bip85.Bip85Entropy.deriveFromHardenedPath(
xprvBase58: xprvBase58,
path: bip85.Bip85HardenedPath(e.path),
);
return (derivation: e, entropy: entropy);
}).toList();
final derivationsWithEntropy = value
.where((e) {
// A row is bound to the root key that created it. If that key is
// no longer available, never show a value derived from another
// wallet in its place.
final key = bip32.Bip32Keys.fromBase58(xprvBase58);
return e.xprvFingerprint.toLowerCase() ==
hex.encode(key.fingerprint).toLowerCase();
})
.map((e) {
final entropy = bip85.Bip85Entropy.deriveFromHardenedPath(
xprvBase58: xprvBase58,
path: bip85.Bip85HardenedPath(e.path),
);
return (derivation: e, entropy: entropy);
})
.toList();
return Ok(derivationsWithEntropy);
}
} catch (e, st) {
Expand Down
Loading
Loading