Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e33066d
add joinstr_flutter bindings dependency
kwsantiago Jul 12, 2026
6fedb72
add Joinstr coinjoin proof of concept behind superuser settings
kwsantiago Jul 12, 2026
3512f98
chore(joinstr): pin joinstr_flutter to the merged rust-joinstr commit
kwsantiago Jul 13, 2026
7e6e078
feat(joinstr): surface the real binding error message
kwsantiago Jul 13, 2026
6fc1b69
chore(joinstr): pin bindings to the Android TLS branch for testnet te…
kwsantiago Jul 13, 2026
6350851
chore(joinstr): pin bindings to merged rust-joinstr master
kwsantiago Jul 14, 2026
aa1808f
joinstr: align ui with joinstr-kmp and floresta wallet, fix pool and …
1440000bytes Jul 15, 2026
b8a9b19
test: expand joinstr coverage
1440000bytes Jul 15, 2026
31de259
chore: bump joinstr_flutter to socks5-capable bindings
1440000bytes Jul 15, 2026
b4ac733
joinstr: route all relay and electrum traffic over tor
1440000bytes Jul 15, 2026
0b393bf
joinstr: match reference wallet ux with waiting dialog and snackbars
1440000bytes Jul 15, 2026
2bdab00
wallet: add joinstr entry to the home screen
1440000bytes Jul 15, 2026
d0955f5
test: cover tor proxy wiring in joinstr usecases
1440000bytes Jul 15, 2026
339c2fd
fix(joinstr): reserve the output address across retries to avoid gap-…
kwsantiago Jul 15, 2026
1d6467e
fix(joinstr): surface the scanned address range in the no-eligible-co…
kwsantiago Jul 15, 2026
e7e3ac5
chore(joinstr): pin bindings to rust-joinstr master tip d1bb9f8 (sock…
kwsantiago Jul 16, 2026
7777de6
joinstr: fix pool creation flow and rename to coinjoin
1440000bytes Jul 17, 2026
0963bf5
settings: remove redundant coinjoin entry now on the home screen
1440000bytes Jul 17, 2026
c84ef88
joinstr: wait for embedded tor to bootstrap before creating or joining
1440000bytes Jul 17, 2026
13e5bcb
chore: bump joinstr_flutter to progress-stream bindings
1440000bytes Jul 17, 2026
28f5786
joinstr: coin-first create, user-selected input, multiple pools, live…
1440000bytes Jul 17, 2026
925ebb0
test: cover coin listing, stream progress and coin-first create
1440000bytes Jul 17, 2026
086b944
fix: advance rounds from their latest state in the coinjoin progress …
1440000bytes Jul 19, 2026
66309fe
fix: mark a round failed when the progress stream ends without a result
1440000bytes Jul 19, 2026
37d80e4
fix: serialize history appends so concurrent rounds cannot drop an entry
1440000bytes Jul 19, 2026
02fa4d7
fix: seed seen rounds and error when the joinstr screen reattaches
1440000bytes Jul 19, 2026
3651fe9
fix: initialize the joinstr bindings on first use instead of at app s…
1440000bytes Jul 19, 2026
f356769
fix: do not mark a round broadcast when the done update carries no txid
1440000bytes Jul 19, 2026
742605d
fix: dispose the relay dialog text controller
1440000bytes Jul 19, 2026
aa07d11
fix: guard load against concurrent runs from route rebuilds
1440000bytes Jul 19, 2026
f5e9bb6
refactor: drop the dead typed-denomination flow
1440000bytes Jul 19, 2026
c2c8766
refactor: use BBInputText for the joinstr numeric fields
1440000bytes Jul 19, 2026
d5e0050
fix: honor electrum server priority and fallback in joinstr
1440000bytes Jul 19, 2026
f06b613
refactor: format joinstr amounts with the app-wide helpers
1440000bytes Jul 19, 2026
b1a0c21
refactor: use the app snackbar overlay for joinstr notices
1440000bytes Jul 19, 2026
8b8b064
refactor: move electrum url parsing to a shared value object
1440000bytes Jul 19, 2026
60a33ef
refactor: share the round history bookkeeping between initiate and join
1440000bytes Jul 19, 2026
b968ed9
refactor: share the expiry helper and freeze JoinstrRound
1440000bytes Jul 19, 2026
b1de9ce
test: cover the round lifecycle, load guard and history serialization…
1440000bytes Jul 19, 2026
02f52e0
fix: surface a failed coin scan instead of a misleading empty state
1440000bytes Jul 19, 2026
421b2ca
fix: re-pin joinstr to batched coin scan so create-pool loads fast
1440000bytes Jul 20, 2026
d437dee
fix: re-pin joinstr so coin scan works against blockstream electrs
1440000bytes Jul 20, 2026
0c8fc0d
feat: reference-style coinjoin timeline and faster, quieter pool loading
1440000bytes Jul 21, 2026
89ccbd5
fix: recover coinjoins from dropped tor connections; no Connecting st…
1440000bytes Jul 21, 2026
9baf946
fix: re-pin joinstr to input circuit isolation
1440000bytes Jul 21, 2026
5a32493
revert: drop mid-round circuit rotation that deadlocked the coinjoin
1440000bytes Jul 21, 2026
fe5da75
feat: reliable private coinjoin with a detailed timeline
1440000bytes Jul 21, 2026
680d674
fix: re-pin joinstr for the tor circuit connect-timeout fix
1440000bytes Jul 21, 2026
666abda
chore(joinstr): pin bindings to the merged #40 tip 6e4e2e3 (coinjoin …
kwsantiago Jul 27, 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
40 changes: 40 additions & 0 deletions lib/core/electrum/domain/value_objects/electrum_url.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/// Canonical parse of a stored electrum server url into host, port and TLS
/// intent. Lives in the electrum module so every consumer shares one rule
/// instead of re-implementing scheme and port handling.
class ElectrumUrl {
final String host;
final int port;

/// True when the url carries an `ssl://` scheme. Consumers that speak to
/// TLS-only servers (e.g. `:50002`) must preserve this rather than silently
/// downgrading to plaintext.
final bool useSsl;

const ElectrumUrl._({
required this.host,
required this.port,
required this.useSsl,
});

/// Parses `[scheme://]host:port`. Returns null when the host is empty, the
/// port is missing, or the port is outside 1-65535.
static ElectrumUrl? tryParse(String url) {
final trimmed = url.trim();
final schemeEnd = trimmed.indexOf('://');
final scheme = schemeEnd == -1
? ''
: trimmed.substring(0, schemeEnd).toLowerCase();
final hostPort = schemeEnd == -1
? trimmed
: trimmed.substring(schemeEnd + 3);

final colon = hostPort.lastIndexOf(':');
if (colon <= 0 || colon == hostPort.length - 1) return null;

final host = hostPort.substring(0, colon);
final port = int.tryParse(hostPort.substring(colon + 1));
if (port == null || port < 1 || port > 65535) return null;

return ElectrumUrl._(host: host, port: port, useSsl: scheme == 'ssl');
}
}
3 changes: 3 additions & 0 deletions lib/core/utils/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ class PayjoinConstants {
}

class ApiServiceConstants {
// Nostr relay used to advertise and discover joinstr coinjoin pools.
static const String defaultNostrRelayUrl = 'wss://nos.lol';

// Bitcoin mempool
static const bbMempoolUrlPath = 'mempool.bullbitcoin.com';
static const publicMempoolUrlPath = 'mempool.space'; // note: not used
Expand Down
281 changes: 281 additions & 0 deletions lib/features/joinstr/data/joinstr_datasource.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import 'package:bb_mobile/core/electrum/domain/electrum_fallback_runner.dart';
import 'package:bb_mobile/core/utils/amount_conversions.dart';
import 'package:bb_mobile/core/electrum/domain/entities/electrum_server.dart';
import 'package:bb_mobile/core/electrum/domain/errors/electrum_fallback_exception.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/features/joinstr/domain/joinstr.dart';
import 'package:bb_mobile/features/joinstr/domain/joinstr_coin.dart';
import 'package:bb_mobile/features/joinstr/domain/joinstr_progress.dart';
import 'package:bb_mobile/features/joinstr/domain/joinstr_round.dart';
import 'package:joinstr_flutter/joinstr_flutter.dart' as jns;

/// Wraps the joinstr bindings. Everything above this layer works in satoshis
/// and domain entities; the `Ffi*` types and the BTC-denominated pool config
/// do not escape it.
class JoinstrDatasource {
const JoinstrDatasource();

/// Runs an FFI call, translating the binding's `JoinstrError` into a domain
/// [JoinstrException] that carries its real message. Without this the error
/// surfaces as `JoinstrError.toString()`, i.e. "Instance of 'JoinstrError'".
Future<T> _call<T>(Future<T> Function() ffi) async {
// Loads the native library on first use; memoized and retry-safe in the
// bindings, so app startup does not pay for it.
await jns.JoinstrFlutter.init();
try {
return await ffi();
} on jns.JoinstrError catch (e) {
throw JoinstrException(JoinstrIssue.coinjoinFailed, detail: e.message);
} on AllElectrumServersFailedException catch (e) {
throw JoinstrException(JoinstrIssue.coinjoinFailed, detail: e.message);
}
}

/// Runs the FFI coin scan against the active servers in priority order,
/// falling back to the next server when one fails, and returns the winning
/// endpoint alongside the coins so a round talks to the server that worked.
Future<(({String address, int port}), List<jns.FfiCoin>)> _scanCoins({
required String mnemonic,
required List<ElectrumServer> electrumServers,
required jns.BitcoinNetwork network,
String? proxy,
}) {
return runElectrumFallback(
servers: electrumServers,
urlOf: (s) => s.url,
isCustomOf: (s) => s.isCustom,
operation: (server) async {
final endpoint = Joinstr.parseElectrumUrl(server.url);
final coins = await jns.listCoins(
mnemonic: mnemonic,
electrumAddress: endpoint.address,
electrumPort: endpoint.port,
rangeStart: 0,
rangeEnd: Joinstr.scanDepth,
network: network,
proxy: proxy,
);
return (endpoint, coins);
},
);
}

Future<List<JoinstrPool>> listPools({
required String relay,
required Duration back,
required Duration wait,
String? proxy,
}) async {
final pools = await _call(
() => jns.listPools(
back: BigInt.from(back.inSeconds),
timeout: BigInt.from(wait.inMicroseconds),
relay: relay,
proxy: proxy,
),
);

// `FfiPool.network` is not trustworthy: pools published by the rust
// implementation omit the field, so it decodes as mainnet regardless of
// origin. It is deliberately not mapped onto the domain entity.
return pools
.map(
(p) => JoinstrPool(
id: p.id,
rawJson: p.rawJson,
denominationSat: p.denominationSat.toInt(),
peers: p.peers,
expiresAtUnixSec: p.expiresAtUnixSec.toInt(),
relay: p.relay,
feeRateSatPerVb: p.feeRate,
publicKey: p.publicKey,
),
)
.toList();
}

/// Lists the wallet's spendable coins by scanning electrum over Tor. The
/// caller filters these to the coins eligible for a given denomination.
Future<List<JoinstrCoin>> listCoins({
required Wallet wallet,
required String mnemonic,
required List<ElectrumServer> electrumServers,
String? proxy,
}) async {
final (_, coins) = await _call(
() => _scanCoins(
mnemonic: mnemonic,
electrumServers: electrumServers,
network: _network(wallet.network),
proxy: proxy,
),
);
return coins
.map(
(c) => JoinstrCoin(
txid: c.txid,
vout: c.vout,
valueSat: c.valueSat.toInt(),
),
)
.toList();
}

/// Streams coinjoin progress until it broadcasts or the pool times out.
Stream<JoinstrProgress> joinPool({
required JoinstrPool pool,
required Wallet wallet,
required String mnemonic,
required String outputAddress,
required List<ElectrumServer> electrumServers,
required String inputOutpoint,
String? proxy,
}) async* {
final peer = await _peerConfig(
wallet: wallet,
mnemonic: mnemonic,
outputAddress: outputAddress,
electrumServers: electrumServers,
relay: pool.relay,
denominationSat: pool.denominationSat,
inputOutpoint: inputOutpoint,
proxy: proxy,
);
yield* _run(jns.joinCoinjoin(poolRawJson: pool.rawJson, peer: peer));
}

Stream<JoinstrProgress> initiatePool({
required Wallet wallet,
required String mnemonic,
required String outputAddress,
required List<ElectrumServer> electrumServers,
required String relay,
required int denominationSat,
required int feeRateSatPerVb,
required int peers,
required Duration maxDuration,
required String inputOutpoint,
String? proxy,
}) async* {
final peer = await _peerConfig(
wallet: wallet,
mnemonic: mnemonic,
outputAddress: outputAddress,
electrumServers: electrumServers,
relay: relay,
denominationSat: denominationSat,
inputOutpoint: inputOutpoint,
proxy: proxy,
);
yield* _run(
jns.initiateCoinjoin(
config: jns.FfiPoolConfig(
denominationBtc: ConvertAmount.satsToBtc(denominationSat),
fee: feeRateSatPerVb,
maxDuration: BigInt.from(maxDuration.inSeconds),
peers: peers,
network: _network(wallet.network),
),
peer: peer,
),
);
}

/// Maps the binding's progress stream onto the domain, translating a binding
/// error into a [JoinstrException] the way [_call] does for one-shot calls.
Stream<JoinstrProgress> _run(Stream<jns.FfiCoinjoinUpdate> updates) async* {
try {
await for (final u in updates) {
yield JoinstrProgress(
step: _step(u.step),
txId: u.txid,
errorMessage: u.error,
outputEventId: u.outputEventId,
inputEventId: u.inputEventId,
psbt: u.psbt,
);
}
} on jns.JoinstrError catch (e) {
throw JoinstrException(JoinstrIssue.coinjoinFailed, detail: e.message);
}
}

JoinstrRoundStep _step(jns.FfiCoinjoinStep step) => switch (step) {
jns.FfiCoinjoinStep.connecting => JoinstrRoundStep.connecting,
jns.FfiCoinjoinStep.posting => JoinstrRoundStep.posting,
jns.FfiCoinjoinStep.outputRegistration =>
JoinstrRoundStep.outputRegistration,
jns.FfiCoinjoinStep.inputRegistration => JoinstrRoundStep.inputRegistration,
jns.FfiCoinjoinStep.broadcast => JoinstrRoundStep.broadcast,
jns.FfiCoinjoinStep.mined => JoinstrRoundStep.mined,
jns.FfiCoinjoinStep.done => JoinstrRoundStep.done,
jns.FfiCoinjoinStep.failed => JoinstrRoundStep.failed,
jns.FfiCoinjoinStep.other => JoinstrRoundStep.other,
};

Future<jns.FfiPeerConfig> _peerConfig({
required Wallet wallet,
required String mnemonic,
required String outputAddress,
required List<ElectrumServer> electrumServers,
required String relay,
required int denominationSat,
required String inputOutpoint,
String? proxy,
}) async {
final network = _network(wallet.network);

final (endpoint, coins) = await _call(
() => _scanCoins(
mnemonic: mnemonic,
electrumServers: electrumServers,
network: network,
proxy: proxy,
),
);

// Use the coin the user picked. Re-listing here keeps it fresh: a coin that
// was spent since the picker loaded is simply gone from the wallet.
jns.FfiCoin? input;
for (final c in coins) {
if ('${c.txid}:${c.vout}' == inputOutpoint) {
input = c;
break;
}
}
if (input == null) {
throw JoinstrException(JoinstrIssue.coinUnavailable);
}

// The window is enforced by every other peer only *after* we broadcast a
// SIGHASH_ALL|SIGHASH_ANYONECANPAY signature over the coin, so an
// ineligible coin must never reach the signer.
if (!Joinstr.isEligibleCoin(
valueSat: input.valueSat.toInt(),
denominationSat: denominationSat,
)) {
throw JoinstrException(
JoinstrIssue.noEligibleCoin,
denominationSat: denominationSat,
);
}

return jns.FfiPeerConfig(
mnemonic: mnemonic,
electrumAddress: endpoint.address,
electrumPort: endpoint.port,
input: input,
outputAddress: outputAddress,
relay: relay,
network: network,
proxy: proxy,
);
}

jns.BitcoinNetwork _network(Network network) => switch (network) {
Network.bitcoinMainnet => jns.BitcoinNetwork.bitcoin,
Network.bitcoinTestnet => jns.BitcoinNetwork.testnet,
Network.liquidMainnet ||
Network.liquidTestnet => throw JoinstrException(JoinstrIssue.bitcoinOnly),
};
}
55 changes: 55 additions & 0 deletions lib/features/joinstr/data/joinstr_store.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'dart:convert';

import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/key_value_storage_datasource.dart';
import 'package:bb_mobile/features/joinstr/domain/joinstr_history_entry.dart';

/// Persists the joinstr relay preference and coinjoin history. Backed by the
/// app's secure key-value storage: which coins were mixed and when is exactly
/// the linkage a coinjoin exists to hide, so it never lands in plain storage.
class JoinstrStore {
static const relayKey = 'joinstr_relay';
static const historyKey = 'joinstr_history';

final KeyValueStorageDatasource<String> _storage;

/// Chains history writes: append is a read-modify-write, and several rounds
/// can complete at once, so unserialized appends could drop an entry.
Future<void> _historyWrites = Future.value();

JoinstrStore(this._storage);

Future<String?> getRelay() => _storage.getValue(relayKey);

Future<void> saveRelay(String relay) =>
_storage.saveValue(key: relayKey, value: relay.trim());

Future<List<JoinstrHistoryEntry>> getHistory() async {
final raw = await _storage.getValue(historyKey);
if (raw == null || raw.isEmpty) return const [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
return decoded
.whereType<Map<String, dynamic>>()
.map(JoinstrHistoryEntry.fromJson)
.whereType<JoinstrHistoryEntry>()
.toList();
} on FormatException {
return const [];
}
}

Future<void> appendHistory(JoinstrHistoryEntry entry) {
final write = _historyWrites.then((_) async {
final history = await getHistory();
final updated = [entry, ...history];
await _storage.saveValue(
key: historyKey,
value: jsonEncode(updated.map((e) => e.toJson()).toList()),
);
});
// A failed write must not poison later appends on the chain.
_historyWrites = write.then((_) {}, onError: (_) {});
return write;
}
}
Loading