Skip to content

Commit 6fedb72

Browse files
committed
add Joinstr coinjoin proof of concept behind superuser settings
1 parent e33066d commit 6fedb72

17 files changed

Lines changed: 1556 additions & 1 deletion

lib/core/utils/constants.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ class PayjoinConstants {
8686
}
8787

8888
class ApiServiceConstants {
89+
// Nostr relay used to advertise and discover joinstr coinjoin pools.
90+
static const String defaultNostrRelayUrl = 'wss://nos.lol';
91+
8992
// Bitcoin mempool
9093
static const bbMempoolUrlPath = 'mempool.bullbitcoin.com';
9194
static const publicMempoolUrlPath = 'mempool.space'; // note: not used
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
2+
import 'package:bb_mobile/features/joinstr/domain/joinstr.dart';
3+
import 'package:joinstr_flutter/joinstr_flutter.dart' as jns;
4+
5+
/// Wraps the joinstr bindings. Everything above this layer works in satoshis
6+
/// and domain entities; the `Ffi*` types and the BTC-denominated pool config
7+
/// do not escape it.
8+
class JoinstrDatasource {
9+
const JoinstrDatasource();
10+
11+
Future<List<JoinstrPool>> listPools({
12+
required String relay,
13+
required Duration back,
14+
required Duration wait,
15+
}) async {
16+
final pools = await jns.listPools(
17+
back: BigInt.from(back.inSeconds),
18+
timeout: BigInt.from(wait.inMicroseconds),
19+
relay: relay,
20+
);
21+
22+
// `FfiPool.network` is not trustworthy: pools published by the rust
23+
// implementation omit the field, so it decodes as mainnet regardless of
24+
// origin. It is deliberately not mapped onto the domain entity.
25+
return pools
26+
.map(
27+
(p) => JoinstrPool(
28+
id: p.id,
29+
rawJson: p.rawJson,
30+
denominationSat: p.denominationSat.toInt(),
31+
peers: p.peers,
32+
expiresAtUnixSec: p.expiresAtUnixSec.toInt(),
33+
relay: p.relay,
34+
feeRateSatPerVb: p.feeRate,
35+
publicKey: p.publicKey,
36+
),
37+
)
38+
.toList();
39+
}
40+
41+
/// Blocks until the coinjoin broadcasts or the pool times out, then returns
42+
/// the txid.
43+
Future<String> joinPool({
44+
required JoinstrPool pool,
45+
required Wallet wallet,
46+
required String mnemonic,
47+
required String outputAddress,
48+
required String electrumUrl,
49+
}) async {
50+
final peer = await _peerConfig(
51+
wallet: wallet,
52+
mnemonic: mnemonic,
53+
outputAddress: outputAddress,
54+
electrumUrl: electrumUrl,
55+
relay: pool.relay,
56+
denominationSat: pool.denominationSat,
57+
);
58+
59+
return jns.joinCoinjoin(poolRawJson: pool.rawJson, peer: peer);
60+
}
61+
62+
Future<String> initiatePool({
63+
required Wallet wallet,
64+
required String mnemonic,
65+
required String outputAddress,
66+
required String electrumUrl,
67+
required String relay,
68+
required int denominationSat,
69+
required int feeRateSatPerVb,
70+
required int peers,
71+
required Duration maxDuration,
72+
}) async {
73+
final peer = await _peerConfig(
74+
wallet: wallet,
75+
mnemonic: mnemonic,
76+
outputAddress: outputAddress,
77+
electrumUrl: electrumUrl,
78+
relay: relay,
79+
denominationSat: denominationSat,
80+
);
81+
82+
return jns.initiateCoinjoin(
83+
config: jns.FfiPoolConfig(
84+
denominationBtc: Joinstr.denominationBtc(denominationSat),
85+
fee: feeRateSatPerVb,
86+
maxDuration: BigInt.from(maxDuration.inSeconds),
87+
peers: peers,
88+
network: _network(wallet.network),
89+
),
90+
peer: peer,
91+
);
92+
}
93+
94+
Future<jns.FfiPeerConfig> _peerConfig({
95+
required Wallet wallet,
96+
required String mnemonic,
97+
required String outputAddress,
98+
required String electrumUrl,
99+
required String relay,
100+
required int denominationSat,
101+
}) async {
102+
final endpoint = Joinstr.parseElectrumUrl(electrumUrl);
103+
final network = _network(wallet.network);
104+
105+
final coins = await jns.listCoins(
106+
mnemonic: mnemonic,
107+
electrumAddress: endpoint.address,
108+
electrumPort: endpoint.port,
109+
rangeStart: 0,
110+
rangeEnd: Joinstr.scanDepth,
111+
network: network,
112+
);
113+
114+
// The window is enforced by every other peer only *after* we broadcast a
115+
// SIGHASH_ALL|SIGHASH_ANYONECANPAY signature over the coin, so an
116+
// ineligible coin must never reach the signer.
117+
final index = Joinstr.selectEligibleCoin(
118+
coinValuesSat: coins.map((c) => c.valueSat.toInt()).toList(),
119+
denominationSat: denominationSat,
120+
);
121+
if (index == null) {
122+
throw JoinstrException(
123+
JoinstrIssue.noEligibleCoin,
124+
denominationSat: denominationSat,
125+
);
126+
}
127+
128+
return jns.FfiPeerConfig(
129+
mnemonic: mnemonic,
130+
electrumAddress: endpoint.address,
131+
electrumPort: endpoint.port,
132+
input: coins[index],
133+
outputAddress: outputAddress,
134+
relay: relay,
135+
network: network,
136+
);
137+
}
138+
139+
jns.BitcoinNetwork _network(Network network) => switch (network) {
140+
Network.bitcoinMainnet => jns.BitcoinNetwork.bitcoin,
141+
Network.bitcoinTestnet => jns.BitcoinNetwork.testnet,
142+
Network.liquidMainnet ||
143+
Network.liquidTestnet => throw JoinstrException(JoinstrIssue.bitcoinOnly),
144+
};
145+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import 'package:bb_mobile/core/errors/bull_exception.dart';
2+
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
3+
4+
enum JoinstrIssue {
5+
bitcoinOnly,
6+
mainnetNotSupported,
7+
watchOnlyWallet,
8+
unsupportedScriptType,
9+
noEligibleCoin,
10+
invalidElectrumUrl,
11+
invalidPoolConfig,
12+
poolNotFound,
13+
coinjoinFailed,
14+
}
15+
16+
class JoinstrException extends BullException {
17+
final JoinstrIssue issue;
18+
final int? denominationSat;
19+
final String? detail;
20+
21+
JoinstrException(this.issue, {this.denominationSat, this.detail})
22+
: super('Joinstr: ${issue.name}');
23+
}
24+
25+
/// A coinjoin pool advertised on a nostr relay.
26+
class JoinstrPool {
27+
final String id;
28+
29+
/// Canonical JSON, passed back to the bindings to join.
30+
final String rawJson;
31+
final int denominationSat;
32+
final int peers;
33+
34+
/// When the pool expires, as a unix timestamp in seconds (absolute, not a
35+
/// duration).
36+
final int expiresAtUnixSec;
37+
final String relay;
38+
final int feeRateSatPerVb;
39+
final String publicKey;
40+
41+
const JoinstrPool({
42+
required this.id,
43+
required this.rawJson,
44+
required this.denominationSat,
45+
required this.peers,
46+
required this.expiresAtUnixSec,
47+
required this.relay,
48+
required this.feeRateSatPerVb,
49+
required this.publicKey,
50+
});
51+
52+
/// Seconds until the pool expires relative to [now], clamped at zero.
53+
int secondsUntilExpiry(DateTime now) {
54+
final remaining = expiresAtUnixSec - now.millisecondsSinceEpoch ~/ 1000;
55+
return remaining > 0 ? remaining : 0;
56+
}
57+
}
58+
59+
abstract final class Joinstr {
60+
/// A joinstr input must satisfy
61+
/// `denomination + 500 <= value <= denomination + 5000`, enforced by every
62+
/// other peer in `CoinJoin::add_input`. A coin outside this window is
63+
/// rejected after we have already published a signature over it, so the
64+
/// window is checked here before anything is signed.
65+
static const int minInputSurplusSat = 500;
66+
static const int maxInputSurplusSat = 5000;
67+
68+
/// Derivation indexes scanned on each branch when listing coins.
69+
static const int scanDepth = 100;
70+
71+
/// Mainnet is withheld until the bindings can route nostr and electrum
72+
/// traffic over Tor. Joining a pool over clearnet reveals the joining IP
73+
/// alongside the outpoint being mixed, which defeats the point.
74+
static const bool mainnetSupported = false;
75+
76+
static bool isEligibleCoin({
77+
required int valueSat,
78+
required int denominationSat,
79+
}) =>
80+
valueSat >= denominationSat + minInputSurplusSat &&
81+
valueSat <= denominationSat + maxInputSurplusSat;
82+
83+
/// Index of the cheapest coin that can fund a [denominationSat] pool, or
84+
/// null when no coin falls inside the window.
85+
static int? selectEligibleCoin({
86+
required List<int> coinValuesSat,
87+
required int denominationSat,
88+
}) {
89+
int? best;
90+
for (var i = 0; i < coinValuesSat.length; i++) {
91+
final value = coinValuesSat[i];
92+
if (!isEligibleCoin(valueSat: value, denominationSat: denominationSat)) {
93+
continue;
94+
}
95+
if (best == null || value < coinValuesSat[best]) best = i;
96+
}
97+
return best;
98+
}
99+
100+
/// Splits a stored electrum url into the address and port the bindings take.
101+
///
102+
/// The `ssl://` prefix is deliberately preserved: joinstr only negotiates TLS
103+
/// when the address starts with it, so stripping the scheme would silently
104+
/// downgrade an SSL-only server such as `:50002` to plaintext.
105+
static ({String address, int port}) parseElectrumUrl(String url) {
106+
final trimmed = url.trim();
107+
final schemeEnd = trimmed.indexOf('://');
108+
final scheme = schemeEnd == -1
109+
? ''
110+
: trimmed.substring(0, schemeEnd).toLowerCase();
111+
final hostPort = schemeEnd == -1
112+
? trimmed
113+
: trimmed.substring(schemeEnd + 3);
114+
115+
final colon = hostPort.lastIndexOf(':');
116+
if (colon <= 0 || colon == hostPort.length - 1) {
117+
throw JoinstrException(JoinstrIssue.invalidElectrumUrl, detail: url);
118+
}
119+
120+
final host = hostPort.substring(0, colon);
121+
final port = int.tryParse(hostPort.substring(colon + 1));
122+
if (port == null || port < 1 || port > 65535) {
123+
throw JoinstrException(JoinstrIssue.invalidElectrumUrl, detail: url);
124+
}
125+
126+
return (address: scheme == 'ssl' ? 'ssl://$host' : host, port: port);
127+
}
128+
129+
/// The bindings take the pool denomination as BTC in a `f64`.
130+
static double denominationBtc(int denominationSat) => denominationSat / 1e8;
131+
132+
/// Throws unless [wallet] can take part in a coinjoin.
133+
///
134+
/// joinstr signs with its own WPKH hot signer derived from the wallet's
135+
/// mnemonic at `m/84'/{0,1}'/0'`, so anything that is not a locally-signed
136+
/// native-segwit bitcoin wallet cannot participate.
137+
static void assertWalletSupported(Wallet wallet) {
138+
if (!wallet.isBitcoin) throw JoinstrException(JoinstrIssue.bitcoinOnly);
139+
if (!wallet.signsLocally) {
140+
throw JoinstrException(JoinstrIssue.watchOnlyWallet);
141+
}
142+
if (wallet.scriptType != ScriptType.bip84) {
143+
throw JoinstrException(JoinstrIssue.unsupportedScriptType);
144+
}
145+
if (!wallet.isTestnet && !mainnetSupported) {
146+
throw JoinstrException(JoinstrIssue.mainnetNotSupported);
147+
}
148+
}
149+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import 'package:bb_mobile/core/utils/constants.dart';
2+
import 'package:bb_mobile/core/utils/logger.dart';
3+
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
4+
import 'package:bb_mobile/features/joinstr/data/joinstr_datasource.dart';
5+
import 'package:bb_mobile/features/joinstr/domain/joinstr.dart';
6+
import 'package:bb_mobile/features/joinstr/domain/usecases/resolve_joinstr_peer_context_usecase.dart';
7+
8+
/// Announces a pool and takes part in it. Blocks until the pool fills and the
9+
/// coinjoin broadcasts, or until [maxDuration] elapses.
10+
class InitiateJoinstrPoolUsecase {
11+
final JoinstrDatasource _datasource;
12+
final ResolveJoinstrPeerContextUsecase _resolvePeerContext;
13+
14+
InitiateJoinstrPoolUsecase({
15+
required this._datasource,
16+
required ResolveJoinstrPeerContextUsecase resolvePeerContextUsecase,
17+
}) : _resolvePeerContext = resolvePeerContextUsecase;
18+
19+
Future<String> execute({
20+
required Wallet wallet,
21+
required int denominationSat,
22+
required int peers,
23+
required int feeRateSatPerVb,
24+
Duration maxDuration = const Duration(hours: 1),
25+
String? relay,
26+
}) async {
27+
final context = await _resolvePeerContext.execute(wallet: wallet);
28+
29+
log.info(
30+
'Joinstr initiating pool: $denominationSat sat, $peers peers, '
31+
'${feeRateSatPerVb}s/vB',
32+
);
33+
34+
try {
35+
return await _datasource.initiatePool(
36+
wallet: wallet,
37+
mnemonic: context.mnemonic,
38+
outputAddress: context.outputAddress,
39+
electrumUrl: context.electrumUrl,
40+
relay: relay ?? ApiServiceConstants.defaultNostrRelayUrl,
41+
denominationSat: denominationSat,
42+
feeRateSatPerVb: feeRateSatPerVb,
43+
peers: peers,
44+
maxDuration: maxDuration,
45+
);
46+
} on JoinstrException {
47+
rethrow;
48+
} catch (e) {
49+
log.severe(error: e, trace: StackTrace.current);
50+
throw JoinstrException(JoinstrIssue.coinjoinFailed, detail: e.toString());
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)