Skip to content

Fix multiple bugs - #40

Merged
kwsantiago merged 21 commits into
masterfrom
pr-coinjoin-fixes
Jul 26, 2026
Merged

Fix multiple bugs #40
kwsantiago merged 21 commits into
masterfrom
pr-coinjoin-fixes

Conversation

@1440000bytes

@1440000bytes 1440000bytes commented Jul 21, 2026

Copy link
Copy Markdown

These bugs were discovered while working on bull wallet implementation in SatoshiPortal/bullbitcoin-mobile#2441

Summary by CodeRabbit

  • New Features
    • Added real-time coinjoin progress streaming with step-by-step updates and terminal done/failed payloads (txid and PSBT when available).
    • Enhanced wallet scanning with batched discovery, cross-boundary handling, and gap-limit stopping.
    • Improved Electrum robustness with reconnect and single retry for retryable failures.
    • Added relay-confirmed pool-message delivery with isolated/detached sending and confirmation timeouts.
  • Bug Fixes
    • Prevented SOCKS5 handshake timeouts from affecting subsequent network operations.
  • Tests
    • Added scan integration coverage and a BIP84 testnet derivation vector.

list_coins swallowed every per-address query error, so a failing scan (eg electrum unreachable over tor) looked identical to an empty wallet. Track the first error and return it when the scan finds nothing, so the caller sees the real cause. Add a BIP84 testnet derivation vector test proving the hot signer derives the same addresses a BIP84 wallet funds.
list_coins issued two electrum requests per address (get_history then get_tx per hit), so a 100-index scan was ~200+ sequential round trips. Over tor that hangs for minutes on the create-pool screen. Batch listunspent across the whole range in chunks: a handful of round trips, and listunspent already returns only unspent outputs so spent coins are no longer offered. Covered with a regtest test that funds addresses and asserts the batched scan finds exactly them.
blockstream's electrs-esplora rejects a request batch above ~20 items, returning EOF, which made every chunk of the 50-wide scan fail and the wallet look empty. Verified live against blockstream: batches of 10 and 20 succeed, 25+ fail. Drop the batch to 10 for margin across servers. Add regtest coverage for a coin on a change address and a coin past the first batch boundary.
The scan walked the whole 100-index range every time, ~20 batched round trips over tor, and one dropped circuit failed it outright. Stop after a 20-index gap of empty addresses (like a wallet stop gap) so a typical wallet finishes in a batch or two, and retry a batch that errors before giving up. Verified live against blockstream: the change-address coin now scans in ~3s instead of minutes. Covered with a regtest test asserting a coin past the gap is skipped.
A coinjoin waits minutes over tor while peers register, long enough for the electrum connection to go idle and drop. The next request (input verification, broadcast) then failed the whole round with WouldBlock or connection-closed, because nothing reconnected. The electrum client now reconnects once and retries on a transport error, and clone/reconnect remember whether the url was ssl so a rebuilt connection keeps speaking TLS (previously a clone reconnected in plaintext to an ssl port). Also clear the leftover SOCKS handshake read/write timeout before returning the socket, so a slow coinjoin read blocks instead of surfacing WouldBlock. Verified live: get_tx over ssl still works after reconnect; regtest covers recovery after reconnect.
A peer registered its output and its input over the same relay connection, so the same tor exit IP carried both. The relay could then link a peer's input to its output and undo the coinjoin. Rotate to a fresh NostrClient (new SOCKS isolation token, new circuit) between output and input registration, keeping the pool keys and re-subscribing to the pool DMs with no since bound so no already-posted message is missed. Full regtest coinjoin round still completes.
Rotating the nostr connection between output and input registration dropped the connection while the output event could still be buffered (post is fire-and-forget, no relay OK is awaited), so over a real relay the output never reached it and the joiner deadlocked waiting for it. Reference clients keep delivery reliable by awaiting the relay OK; restore the single-connection flow that works, then re-add circuit isolation the reference way (post each registration over its own circuit and wait for the OK).
Post each output and input registration over its own fresh connection (new SOCKS isolation token, new tor circuit) and wait for the relay OK before proceeding, matching the reference clients. This makes a peer's input unlinkable from its output by exit IP and stops a fire-and-forget event from being silently dropped (the cause of the joiner deadlock). The relay OK also yields the event id: current_progress now carries the output/input event ids and the finalized psbt, streamed to the caller (FfiCoinjoinUpdate gains output_event_id/input_event_id/psbt) so the timeline can show them. Full regtest coinjoin round still completes; bindings regenerated.
Measured against a real tor instance: SOCKS isolation tokens reliably force a new circuit (0/12 failed, output and input always landed on different exit IPs across 3 trials), so the per-registration circuit design is right. The failure was the 10s connect budget: building a fresh circuit occasionally took 12s+ in testing, worse on a phone's embedded tor, and surfaced as 'socks5 connect failed' mid-coinjoin. Widen the connect/handshake timeouts and retry an isolated send up to 3 times, each attempt drawing a new circuit so a slow or dead one is routed around instead of failing the round.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds coinjoin progress streaming from Rust through Dart FFI, records relay-confirmed registration details, introduces Electrum batching and reconnect retries, expands scanning tests, and centralizes connection timeout handling.

Changes

Coinjoin progress streaming

Layer / File(s) Summary
Progress state and registration
rust/joinstr/src/joinstr/mod.rs, rust/joinstr/src/nostr/sync/mod.rs, rust/simple_nostr_client/src/lib.rs
Joinstr stores progress details and obtains confirmed relay event IDs for output and PSBT registration.
Progress-aware coinjoin execution
rust/joinstr/src/interface.rs
Coinjoin operations run with worker-thread polling and callback-based progress reporting.
Dart progress contract and bridge
dart/rust/src/api/types.rs, dart/rust/src/api/joinstr.rs, dart/rust/src/frb_generated.rs, dart/ios/Classes/frb_generated.h
FFI progress types, codecs, stream sinks, and exported entry points carry progress, completion, and failure updates to Dart.

Wallet scanning and network resilience

Layer / File(s) Summary
Electrum batching and recovery
rust/joinstr/src/electrum.rs
Electrum operations batch requests, verify responses, reconnect for retryable failures, and classify broadcast results.
Batched wallet scanning
rust/joinstr/src/interface.rs, rust/joinstr/src/signer/mod.rs
Wallet scanning batches receive/change paths, retries failed batches, applies a gap limit, and exposes batched coin retrieval.
Scanning and derivation validation
rust/joinstr/tests/scan.rs, rust/joinstr/src/signer/mod.rs
Tests cover receive/change coins, batch boundaries, gaps, reconnects, empty wallets, and BIP84 derivation.
Shared connection timeout handling
rust/socks5/src/lib.rs, rust/simple_nostr_client/src/lib.rs, rust/simple_electrum_client/src/raw_client/*
Connection timeout constants are shared and handshake socket timeouts are cleared after setup.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dart
  participant RustApi
  participant CoinjoinWorker
  participant Joinstr
  participant Relay
  Dart->>RustApi: start coinjoin with progress sink
  RustApi->>CoinjoinWorker: run coinjoin with callback
  CoinjoinWorker->>Joinstr: execute registration and coinjoin steps
  Joinstr->>Relay: send isolated pool message
  Relay-->>Joinstr: confirmed event ID
  Joinstr-->>RustApi: CoinjoinProgress
  RustApi-->>Dart: progress, done, or failed update
Loading

Possibly related PRs

  • rust-joinstr/joinstr#31: Modifies the coinjoin execution plumbing and joining flow in rust/joinstr/src/interface.rs.

Suggested reviewers: kwsantiago

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is too generic and does not describe the specific fixes in the pull request. Use a concise title naming the main change, e.g. "Add coinjoin progress reporting and Electrum retry fixes".
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr-coinjoin-fixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/joinstr/src/electrum.rs`:
- Around line 174-192: Preserve the certificate-verification setting across
client reconstruction: add a Client field for the setting, initialize it to
false in new_local and true in other constructors, and apply it when rebuilding
RawClient. Update Clone::clone and reconnect to call verif_certificate using the
stored value, while retaining the existing URL, port, proxy, and SSL behavior.
- Around line 174-192: Update Client::clone to avoid constructing or
reconnecting a network client, keeping cloning cheap and infallible while
preserving the client’s connection configuration and state as appropriate. Add a
fallible try_clone or reconnect method for callers that need a fresh connection,
moving the current RawClient::new_ssl_maybe, proxy setup, and try_connect
behavior there, and update fresh-connection call sites to use it.
- Around line 292-376: Update list_unspent_batch_inner to handle Response::Error
alongside Response::SHListUnspent: when its request ID exists in position,
remove the ID from position and self.index, decrement pending, and propagate the
contained error immediately. Preserve cleanup of any remaining pending IDs
before returning.
- Around line 54-61: Update the error flow used by broadcast_inner() to
represent server-side broadcast rejections with a distinct non-retryable Error
variant instead of Error::Electrum(...). Adjust is_retryable() so only
transport/framing Electrum errors and WrongResponse remain retryable, and update
any matching or propagation sites required for the new variant.

In `@rust/joinstr/src/interface.rs`:
- Around line 188-242: The scan currently suppresses failed-batch errors
whenever other batches return coins, allowing incomplete results to appear
successful. Update the error handling after signer.list_coins() so any recorded
first_error is propagated regardless of whether coins is empty or non-empty,
preserving successful results only when every batch completes successfully.

In `@rust/joinstr/src/joinstr/mod.rs`:
- Around line 882-901: Update the registration flows centered on
register_output, register_input, register_outputs, and start_coinjoin_blocking
so no inner mutex guard remains held during send_pool_message_isolated or other
network waits: capture required state while locked, release the guard, perform
the call, then briefly re-acquire it to store event IDs and registration data.
Also make current_step and current_progress recover from a poisoned mutex by
reading the inner value instead of panicking, preserving progress polling and
controlled worker-panic handling.

In `@rust/joinstr/src/nostr/sync/mod.rs`:
- Around line 198-232: The retry loop in send_pool_message_isolated can resend a
registration after a local confirmation timeout, creating duplicate Output
events. Make retries idempotent by verifying the prior event was not accepted
before resending, or by preserving the sender public key through
try_receive_pool_msg and deduplicating registrations in
Joinstr::receive_outputs; ensure duplicate outputs do not increase
coinjoin.outputs_len() or invalidate register_outputs participant checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ee3b6cdf-0216-497a-ae12-4e151f572ab6

📥 Commits

Reviewing files that changed from the base of the PR and between d1bb9f8 and 6c284af.

⛔ Files ignored due to path filters (4)
  • dart/lib/src/generated/api/joinstr.dart is excluded by !**/generated/**
  • dart/lib/src/generated/api/types.dart is excluded by !**/generated/**
  • dart/lib/src/generated/frb_generated.dart is excluded by !**/generated/**
  • dart/lib/src/generated/frb_generated.io.dart is excluded by !**/generated/**
📒 Files selected for processing (12)
  • dart/ios/Classes/frb_generated.h
  • dart/rust/src/api/joinstr.rs
  • dart/rust/src/api/types.rs
  • dart/rust/src/frb_generated.rs
  • rust/joinstr/src/electrum.rs
  • rust/joinstr/src/interface.rs
  • rust/joinstr/src/joinstr/mod.rs
  • rust/joinstr/src/nostr/sync/mod.rs
  • rust/joinstr/src/signer/mod.rs
  • rust/joinstr/tests/scan.rs
  • rust/simple_nostr_client/src/lib.rs
  • rust/socks5/src/lib.rs

Comment thread rust/joinstr/src/electrum.rs
Comment thread rust/joinstr/src/electrum.rs
Comment thread rust/joinstr/src/electrum.rs
Comment thread rust/joinstr/src/interface.rs
Comment thread rust/joinstr/src/joinstr/mod.rs
Comment on lines +198 to +232
pub fn send_pool_message_isolated(
&self,
npub: &PublicKey,
msg: PoolMessage,
proxy: Option<String>,
) -> Result<EventId, Error> {
let relay = self.get_relay().ok_or(Error::NotConnected)?;
let keys = self.get_keys()?.clone();
let content = msg.to_string()?;

// Each attempt opens a new connection, which draws a new SOCKS isolation
// token and therefore a different tor circuit. Building a fresh circuit
// occasionally overshoots the connect budget or picks a dead relay; a
// retry routes around it on another circuit instead of failing the round.
let mut last_err = None;
for attempt in 0..ISOLATED_SEND_ATTEMPTS {
let result = WsClient::new()
.relay(relay.clone())
.proxy(proxy.clone())
.keys(keys.clone())
.connect()
.and_then(|mut c| c.send_dm_confirmed(content.clone(), npub, CONFIRM_TIMEOUT));
match result {
Ok(id) => return Ok(id),
Err(e) => {
log::warn!("send_pool_message_isolated attempt {attempt} failed: {e:?}");
last_err = Some(e);
if attempt + 1 < ISOLATED_SEND_ATTEMPTS {
std::thread::sleep(Duration::from_secs(2));
}
}
}
}
Err(last_err.expect("at least one attempt").into())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Retry-on-timeout can duplicate output/input registration; receiving side has no dedup.

If an attempt's send_dm_confirmed fails only because the local wait for OK timed out (not because the relay actually rejected the event), the retry sends the same registration again from a new connection — but with the same identity keys. Both events can land on the relay. On the receiving side, Joinstr::receive_outputs has an existing // FIXME: should we check if the output have been added? and try_receive_pool_msg doesn't even retain the sender pubkey for PoolMessage::Output, so there's no way to detect or drop the duplicate. A spurious retry can therefore inflate coinjoin.outputs_len() with a phantom extra output, breaking the payload.peers participant-count check in register_outputs.

This retry design meaningfully increases how often that pre-existing gap gets exercised. Consider making retries idempotent (e.g. verify the previous event actually wasn't accepted before resending, or dedupe registrations by sender pubkey on the receiving side).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/nostr/sync/mod.rs` around lines 198 - 232, The retry loop in
send_pool_message_isolated can resend a registration after a local confirmation
timeout, creating duplicate Output events. Make retries idempotent by verifying
the prior event was not accepted before resending, or by preserving the sender
public key through try_receive_pool_msg and deduplicating registrations in
Joinstr::receive_outputs; ensure duplicate outputs do not increase
coinjoin.outputs_len() or invalidate register_outputs participant checks.

@kwsantiago kwsantiago left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the electrum batching rewrite and the confirmed-send path. Tor plumbing itself looks right: no clearnet fallback on any of the touched connect sites, remote DNS is genuine, and the reverted mid-round rotation is superseded by a fresh isolated client per registration. Blockers are concentrated in the batch scan and the new send_pool_message_isolated retry.

Comment thread rust/joinstr/src/electrum.rs Outdated
}
};
for response in responses {
if let Response::SHListUnspent(r) = response {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending only decrements on Response::SHListUnspent with a matching id. An electrum error for one batch element parses as Response::Error, so pending never reaches 0 and the loop re-enters inner.recv(), which blocks in read_line with no read timeout (the client never sets one, and socks5::connect clears the handshake deadline before returning the socket).

A server that answers one of the ten ids with a JSON-RPC error and keeps the socket open hangs the scan or coinjoin thread forever. with_reconnect can't help because no error is ever returned. The replaced get_coins_at did a single recv and returned WrongResponse, so this is a regression.

Decrement on any response whose id is in position, surface Response::Error, and put a deadline on the while pending > 0 loop.

/// Whether this error is worth reconnecting and retrying once. Transport and
/// framing failures (dropped/stale tor circuit, `WouldBlock`, a desynced
/// response) are; logical outcomes (tx absent, unparseable) are not.
fn is_retryable(&self) -> bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

broadcast_inner returns Error::Electrum(..) for server rejections (bad fee, conflicting spend), not just transport failures. Those are classified retryable here, so with_reconnect tears down the circuit and rebroadcasts a tx the server already definitively rejected, then reports the second attempt's error instead of the first.

Separate transport errors (the From<raw_client::Error> path) from application-level rejections, or keep broadcast out of with_reconnect.

// Preserve ssl too, or the clone would reconnect in plaintext.
let mut inner =
RawClient::new_ssl_maybe(&self.url, self.port, self.ssl).proxy(self.proxy.clone());
inner.try_connect().expect("electrum reconnect on clone");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clone now performs a full connect and panics on failure. WpkhHotSigner derives Clone, so this is a network-triggerable panic reachable from start_coinjoin. Over tor it is likely, since the electrum connect timeout is still 10s while nostr's went to 60s (see simple_nostr_client/src/lib.rs).

Worse, the panic can fire while inner is held, poisoning the mutex so the progress poller's .expect("poisoned") panics too instead of yielding CoinjoinThreadPanicked.

Also: new_local builds the inner client with .verif_certificate(false) but Client doesn't record that, so both this and reconnect() rebuild with the verifying config. A regtest/self-signed client fails TLS on its first reconnect, and here that failure is a panic. Store the flag next to ssl.

}

let client = self.client.as_mut().ok_or(Error::NoElectrumClient)?;
let batch = client.list_unspent_batch(&spks)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batch path builds TxOut straight from the server's listunspent reply. The replaced get_coins_at fetched the raw tx and asserted txout.script_pubkey == *script, so both value and outpoint were verified.

A hostile electrum server can now inject phantom outpoints or inflated values into the coin picker. The BIP143 sighash commits to the false amount and the coinjoin tx is invalid at broadcast, after this peer has already published its input outpoint and a fresh output address to the pool. Consensus bounds the fund loss; the anonymity-set burn is real and cheap to trigger.

Also, coin_paths.iter().zip(batch) correlates positionally. list_unspent_batch does return in request order, but its recv error path breaks with a partial out, which would misattribute coins to the wrong derivation path. Key by request id instead.

Comment thread rust/joinstr/src/joinstr/mod.rs Outdated
// than the output) and wait for the relay OK.
let event_id =
self.client
.send_pool_message_isolated(&npub, msg, self.proxy.clone())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

register_input does self.input.take() at :1555, before this send. The send is now confirmed and retried (3 attempts x 60s connect + 30s handshake + 30s OK wait).

A relay that OKs the output registration and then withholds OK on the input leaves the peer, after ~6 minutes, with its fresh output address already published and pool-associated, and self.input consumed so state() serializes input: None and restart() can never re-register it. Under the old fire-and-forget send_pool_message this path essentially never failed; Error::OkTimeout makes it reachable.

Take the input only after a confirmed send, or restore it on error.

// occasionally overshoots the connect budget or picks a dead relay; a
// retry routes around it on another circuit instead of failing the round.
let mut last_err = None;
for attempt in 0..ISOLATED_SEND_ATTEMPTS {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worst case per call is 3 x (60s connect + 30s confirm + 2s sleep) ~= 4.6 min, and this runs twice per round (output then input). Both callers hold the JoinstrInner lock across it (joinstr/mod.rs:707, :1040), so ~9 min can be spent against a pool timeout that is usually shorter: the retries make the round die rather than survive.

The held lock also means the new progress poller blocks during the longest step, so nothing is streamed exactly where the feature is most useful. And a stalling relay keeps the instance pinned while simple_nostr_client's unbounded mpsc queue accumulates 512 KiB messages with no count cap.

Budget the total against end_timeline instead of a fixed attempt count, and drop the lock around the send.

Comment thread rust/simple_nostr_client/src/lib.rs Outdated
/// returns once a fresh circuit is built, which was measured to spike past 12s;
/// on a phone's embedded tor it is slower still. A 10s cap surfaced as
/// "socks5 connect failed" mid-coinjoin.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(60);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This widening is correct, but the electrum side was left behind: simple_electrum_client keeps CONNECT_TIMEOUT = 10s in both tcp_client.rs and ssl_client.rs. The reasoning in this comment applies identically there, so Client::reconnect() over tor (the point of 5fd842c) will keep failing with "socks5 connect failed", and via Clone that failure is a panic.

Worth hoisting one constant into the socks5 crate so all three paths move together.

Comment thread rust/joinstr/src/interface.rs Outdated
let coins = signer.list_coins().into_iter().map(|c| c.1).collect();
let coins: Vec<Coin> = signer.list_coins().into_iter().map(|c| c.1).collect();

if coins.is_empty() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A batch that fails all BATCH_RETRIES sets first_error, but it is only surfaced when the coin list is empty. If any other batch found a coin, the failure is dropped and the caller gets a silently partial coin set, then picks an input from that partial view. That is the opposite of "surface electrum scan failures".

Compounding it: a failed batch contributes nothing to consecutive_empty and nothing to the results, so the caller can't distinguish "no coins" from "half the scan failed". Return the error whenever any batch failed, or signal partiality explicitly.

Some(n) if n > 0 => consecutive_empty = 0,
Some(_) => {
consecutive_empty += end - index;
if consecutive_empty >= SCAN_STOP_GAP {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gap limit stops the scan regardless of the range the caller asked for, so list_coins(.., (0, 100), ..) reports "no coins" for a wallet whose funds sit past index 20, with no signal. tests/scan.rs:104 encodes this as intended, but from the FFI it is a silent behavior change from the previous full-range scan. At minimum surface "stopped at gap" to the caller.

Minor, same block: let end = (index + INDEXES_PER_BATCH).min(range.1) at :197 overflows for a range near u32::MAX (check_scan_range only bounds the span), and consecutive_empty += end - index then underflows. saturating_add/saturating_sub.

Comment thread dart/rust/src/api/joinstr.rs Outdated
let _ = progress.add(FfiCoinjoinUpdate::failed(e.to_string()));
}
}
Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed coinjoin is reported only through the stream, and the function still returns Ok(()). If the Dart subscription is cancelled the sink add fails, let _ = discards it, and the call reads as success. Same in initiate_coinjoin. Return Err(e.into()) in addition to pushing the update.

Unrelated but adjacent: FfiCoinjoinUpdate::done/failed null out output_event_id, input_event_id and psbt, so a UI rendering the latest update loses them exactly at the terminal step.

list_unspent_batch only decremented its pending count on SHListUnspent, so an electrum error for one id in the batch left that id pending forever. recv() then blocked in read_line with no read timeout, hanging the scan or coinjoin thread with no error for with_reconnect to act on. The replaced get_coins_at returned WrongResponse after a single recv, so this was a regression. Account for any response carrying one of our ids, surface Response::Error, and bound the collect loop with a deadline.
The batch path built each coin straight from the server's listunspent reply, so a hostile electrum server could inject outpoints that do not exist or inflate their value. Signing commits to the amount, so a lie produced an invalid signature and killed the coinjoin only after the peer had published its input outpoint and a fresh output address to the pool. The replaced per-address path fetched the transaction and matched its script_pubkey; do the same for every candidate and keep the chain's value, fetching only coins that were actually found. Also assert the batch length so positional results cannot be attributed to the wrong derivation path.
The connect timeout was raised for the nostr relay but both electrum clients kept a 10s cap, so Client::reconnect() over tor kept failing with the same 'socks5 connect failed' the widening was meant to fix, and via Clone that failure is a panic. Hoist one CONNECT_TIMEOUT/HANDSHAKE_TIMEOUT pair into the socks5 crate that every dialer shares, so the paths cannot drift apart again.
register_input took self.input before publishing, which was harmless while the send was fire-and-forget. Waiting for the relay OK makes failure reachable (OkTimeout, rejection), and the input was then gone: state() serialized input: None so restart() could never re-register it, stranding a peer whose fresh output address was already published and pool-associated. Restore the input when the send fails.
register_output and register_input ran the isolated send while their caller held the inner lock. Publishing opens a fresh connection per attempt and can take minutes, so current_step and current_progress blocked on the same mutex for that whole window: the poller saw one coalesced update instead of the streaming progress the FFI exposes, exactly during the longest step. A panic there also poisoned the mutex, so the poller's own lock panicked instead of the worker's join surfacing CoinjoinThreadPanicked. Split each registration into prepare (locked), send (unlocked) and commit (locked), and let the pollers recover from poisoning. Committing only after a confirmed send also means the input is cleared only once it is really registered.
A batch that exhausted its retries only surfaced when the whole scan came back empty, so if any other batch found a coin the failure was dropped and the caller received a silently partial set, then picked an input from that incomplete view. Return the error whenever a batch failed, so an incomplete scan cannot pass for a complete one.
…econnect

is_retryable matched every Error::Electrum, but broadcast_inner used that variant for server rejections too (bad fee, conflicting spend). A rejected broadcast was therefore torn down and resent, and the caller saw the second attempt's error instead of the verdict. Give rejections their own non-retryable variant. Separately, Client never recorded new_local's verif_certificate(false), so a self-signed client silently rebuilt with strict verification on clone or reconnect and failed TLS; store the flag alongside ssl and reapply it.
check_scan_range bounds the span but not the start, so a range near u32::MAX overflowed computing the batch end and then underflowed accumulating the empty-index gap.
initiate_coinjoin and join_coinjoin reported failures only through the progress sink and still returned Ok, so a cancelled Dart subscription (where add is dropped) turned a failed coinjoin into a success. Return the error as well. The terminal done/failed updates also nulled out output_event_id, input_event_id and psbt, losing them for any consumer that renders just the latest update; carry the last observed detail into them.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
rust/joinstr/src/electrum.rs (3)

236-285: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

reconnect() (and by the same defect, Clone) drop the new_local certificate-verification override.

reconnect() rebuilds via RawClient::new_ssl_maybe(&self.url, self.port, self.ssl).proxy(self.proxy.clone()) — no .verif_certificate(false) call — and Client has no field recording that it was built with new_local() (line 274 sets .verif_certificate(false) on the initial connect only; the struct only stores ssl, not verification mode). Since with_reconnect now wraps every single operation (list_unspent_batch, get_tx, get_coins_tx_at, broadcast), a regtest/self-signed client will fail its TLS handshake on the very first retryable failure of any call, not just on an explicit reconnect()/clone() invocation as before — this is a regression in blast radius versus the prior state.

🔧 Suggested fix
 pub struct Client {
     inner: RawClient,
     index: HashMap<usize, Request>,
     last_id: usize,
     url: String,
     port: u16,
     proxy: Option<String>,
     ssl: bool,
+    verify_certificate: bool,
 }

Set true in new/new_with_proxy, false in new_local, and apply .verif_certificate(self.verify_certificate) in both reconnect() and Clone.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/electrum.rs` around lines 236 - 285, Track the
certificate-verification mode on Client: initialize it to true in new and
new_with_proxy, and false in new_local. Update reconnect() and the Clone
implementation to apply verif_certificate using this stored verify_certificate
value when rebuilding RawClient, preserving new_local’s self-signed certificate
behavior across retries and clones.

358-399: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

No early exit once a batch item errors; loop keeps waiting up to BATCH_TIMEOUT.

Once result becomes Err (390 or 395), the outer while pending > 0 loop keeps running until every remaining id resolves or the full 120s deadline elapses, even though the batch is already known to fail. Breaking as soon as result.is_err() avoids an unnecessary multi-minute wait on the caller thread.

🔧 Suggested fix
-            let responses = match self.inner.recv(&self.index) {
+            let responses = match self.inner.recv(&self.index) {
                 Ok(r) => r,
                 Err(e) => {
                     result = Err(e.into());
                     break;
                 }
             };
             for response in responses {
                 ...
             }
+            if result.is_err() {
+                break;
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/electrum.rs` around lines 358 - 399, Update the
response-processing flow around the `result` assignment and outer `while pending
> 0` loop to stop waiting once any batch item sets `result` to `Err`. Preserve
the existing first-error behavior and response handling, but exit the batch wait
promptly instead of continuing until remaining IDs resolve or `BATCH_TIMEOUT`
expires.

341-357: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound each recv() instead of only checking the deadline before it.

BATCH_TIMEOUT is checked only when the batch loop reaches a new recv() call. Since RawClient defaults to blocked reads and read_line_capped calls stream.read() one byte at a time, the first unread line in pending > 0 can still block indefinitely if the server keeps the socket open but sends no further plaintext. Apply or preserve per-call socket/TLS read timeouts in the underlying client rather than relying on the between-call deadline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/electrum.rs` around lines 341 - 357, Bound each blocking
recv operation in the batch response loop to BATCH_TIMEOUT by configuring or
preserving the underlying RawClient socket/TLS read timeout, rather than
checking the deadline only before recv. Update the client setup used by
self.inner.recv so a stalled read returns an error or timeout and the existing
Error::BatchTimeout handling remains effective.
♻️ Duplicate comments (2)
rust/joinstr/src/nostr/sync/mod.rs (1)

226-244: 🗄️ Data Integrity & Integration | 🟠 Major

The isolated registration retry is still non-idempotent and not deadline-aware.

post_event_confirmed sends the event before waiting for OK; if that wait returns OkTimeout, this loop publishes a second registration. The configured budgets alone allow roughly 3 × (60s + 30s) + 2 × 2s ≈ 274 seconds, independent of the pool deadline. Retry only failures known to occur before publishing, or add idempotency/deduplication and cap retries by the remaining round deadline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/nostr/sync/mod.rs` around lines 226 - 244, The retry loop
around WsClient::connect and send_dm_confirmed must avoid republishing
registrations after an event may already have been sent and must respect the
pool’s remaining deadline. Restrict retries to failures definitively occurring
before publication, or introduce idempotency/deduplication, and cap each
attempt, wait, and retry delay using the remaining round deadline before
returning the last error.
rust/joinstr/src/electrum.rs (1)

62-68: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

is_retryable() still over-matches rejections and now under-matches BatchTimeout.

Two issues in the retryable classification:

  • Error::Electrum(_) still covers broadcast_inner's server-rejection path (bad fee, conflicting spend — line 876), so with_reconnect tears down the circuit and resends a transaction the server already definitively rejected, reporting the second attempt's error instead of the original rejection. Flagged in prior reviews and unchanged here.
  • The new Error::BatchTimeout (line 40) is not included, so a batched request that hits the deadline never gets the one-shot reconnect-and-retry that with_reconnect exists for — exactly the scenario (a possibly-stale/hung connection) this mechanism was designed to recover from.
🔧 Suggested fix
     fn is_retryable(&self) -> bool {
-        matches!(self, Error::Electrum(_) | Error::WrongResponse)
+        matches!(self, Error::WrongResponse | Error::BatchTimeout)
     }

Give broadcast/batch rejections (and any other definitive server-side error) their own non-retryable variant instead of overloading Error::Electrum(_), so transport failures stay retryable while application rejections don't.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/electrum.rs` around lines 62 - 68, Update
Error::is_retryable to include Error::BatchTimeout while excluding definitive
broadcast/batch server rejections from the retryable classification. Introduce
or reuse a distinct non-retryable error variant at the broadcast_inner rejection
path instead of wrapping those outcomes in Error::Electrum(_), preserving retry
behavior for transport/framing failures and preventing resubmission of rejected
transactions.
🧹 Nitpick comments (1)
rust/joinstr/src/electrum.rs (1)

406-430: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Per-coin verification defeats part of the batching win.

get_tx is called once per candidate outpoint (417), sequentially, each going through its own with_reconnect/round trip. This is scoped to only scripts with actual unspent outputs (not the full scanned set), so the primary batching win (avoiding one round trip per address) is preserved, but for wallets with many funded UTXOs this still serializes a round trip per coin over Tor — the exact latency profile batching was meant to avoid. A simple mitigation: cache fetched transactions by txid (a single funding tx can pay multiple scanned addresses/coins) to avoid duplicate fetches; a fuller fix would need a batched tx-fetch API.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/electrum.rs` around lines 406 - 430, Update the
per-candidate verification loop in the outpoint-processing flow to cache fetched
transactions by txid and reuse the cached transaction for subsequent outpoints,
while preserving the existing output-index and script_pubkey validation. Keep
the cache scoped to this batch operation and retain the current error behavior
for missing transactions or invalid outpoints.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/joinstr/src/electrum.rs`:
- Around line 236-285: Track the certificate-verification mode on Client:
initialize it to true in new and new_with_proxy, and false in new_local. Update
reconnect() and the Clone implementation to apply verif_certificate using this
stored verify_certificate value when rebuilding RawClient, preserving
new_local’s self-signed certificate behavior across retries and clones.
- Around line 358-399: Update the response-processing flow around the `result`
assignment and outer `while pending > 0` loop to stop waiting once any batch
item sets `result` to `Err`. Preserve the existing first-error behavior and
response handling, but exit the batch wait promptly instead of continuing until
remaining IDs resolve or `BATCH_TIMEOUT` expires.
- Around line 341-357: Bound each blocking recv operation in the batch response
loop to BATCH_TIMEOUT by configuring or preserving the underlying RawClient
socket/TLS read timeout, rather than checking the deadline only before recv.
Update the client setup used by self.inner.recv so a stalled read returns an
error or timeout and the existing Error::BatchTimeout handling remains
effective.

---

Duplicate comments:
In `@rust/joinstr/src/electrum.rs`:
- Around line 62-68: Update Error::is_retryable to include Error::BatchTimeout
while excluding definitive broadcast/batch server rejections from the retryable
classification. Introduce or reuse a distinct non-retryable error variant at the
broadcast_inner rejection path instead of wrapping those outcomes in
Error::Electrum(_), preserving retry behavior for transport/framing failures and
preventing resubmission of rejected transactions.

In `@rust/joinstr/src/nostr/sync/mod.rs`:
- Around line 226-244: The retry loop around WsClient::connect and
send_dm_confirmed must avoid republishing registrations after an event may
already have been sent and must respect the pool’s remaining deadline. Restrict
retries to failures definitively occurring before publication, or introduce
idempotency/deduplication, and cap each attempt, wait, and retry delay using the
remaining round deadline before returning the last error.

---

Nitpick comments:
In `@rust/joinstr/src/electrum.rs`:
- Around line 406-430: Update the per-candidate verification loop in the
outpoint-processing flow to cache fetched transactions by txid and reuse the
cached transaction for subsequent outpoints, while preserving the existing
output-index and script_pubkey validation. Keep the cache scoped to this batch
operation and retain the current error behavior for missing transactions or
invalid outpoints.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d7916be7-2b85-447d-918b-b8be9c67c5fd

📥 Commits

Reviewing files that changed from the base of the PR and between 6c284af and c4925da.

📒 Files selected for processing (8)
  • rust/joinstr/src/electrum.rs
  • rust/joinstr/src/joinstr/mod.rs
  • rust/joinstr/src/nostr/sync/mod.rs
  • rust/joinstr/src/signer/mod.rs
  • rust/simple_electrum_client/src/raw_client/ssl_client.rs
  • rust/simple_electrum_client/src/raw_client/tcp_client.rs
  • rust/simple_nostr_client/src/lib.rs
  • rust/socks5/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • rust/joinstr/src/signer/mod.rs
  • rust/simple_nostr_client/src/lib.rs
  • rust/joinstr/src/joinstr/mod.rs

@kwsantiago

kwsantiago commented Jul 26, 2026

Copy link
Copy Markdown

Reviewed this while wiring the bindings into the wallet (both a general correctness pass and a security-focused pass). The privacy/consensus-critical parts look solid: SOCKS5 has no direct-connection fallback and still refuses no-auth (isolation preserved), the signer/SIGHASH 0x81 path is unchanged, batch coin verification re-fetches each tx and keeps the chain value (closes the forged-amount / BIP143 vector), TLS+cert policy is carried through reconnect, and the scan gap-limit/saturating math is correct. IP-level unlinkability is correctly restored (input and output register over separate circuits). Nice set of fixes.

One blocker and two smaller items:

Blocker: BATCH_TIMEOUT can't interrupt a stalled read, so a partial electrum reply hangs the coinjoin worker forever

rust/joinstr/src/electrum.rs:359-366 checks Instant::now() >= deadline only between completed line reads, but the electrum socket has no read timeout set anywhere: Client::new_with_proxy/reconnect never call set_read_timeout, ssl_client restores the default None after the handshake, and socks5::connect explicitly sets set_read_timeout(None) (rust/socks5/src/lib.rs:152). A server that answers some batch ids then holds the connection open without sending the trailing \n parks recv -> read_line in a blocking read() indefinitely; the deadline is never re-evaluated. The comment at electrum.rs:27-30/355-358 says the deadline stops exactly this, but it doesn't. Same unbounded blocking applies to get_tx_inner (the chain-verification loop) and broadcast_inner.

Impact: the coinjoin worker wedges, run_coinjoin_with_progress polls handle.is_finished() forever, and no terminal done/failed ever reaches the FFI stream, so the app shows perpetual progress. No fund loss, but it defeats the "terminals always emitted" design.

Suggested fix: set an actual socket read timeout on the electrum client (e.g. after connect, set_read_timeout(Some(...))) and let WouldBlock surface as a retryable Error::Electrum. The wall-clock BATCH_TIMEOUT deadline only works if recv can return between reads.

Medium: a successful broadcast can be reported as a failure

broadcast is wrapped in with_reconnect (electrum.rs:845-846). If the tx reaches the server and is accepted but the response read desyncs (circuit drop), broadcast_inner returns a retryable Error::Electrum/WrongResponse; the retry rebroadcasts, the server replies "already known", and that maps to the non-retryable BroadcastRejected. Net: broadcast_tx returns Err, final_tx is left unset, and the caller sees a failed round even though the tx is on the network. Rebroadcasting the identical tx is harmless (no double-spend), so this is a false-negative, but consider treating "already known" as success or not routing broadcast through with_reconnect.

Low: isolated-send retry can publish the same registration up to 3 times

send_pool_message_detached (rust/joinstr/src/nostr/sync/mod.rs:216-247) opens a fresh circuit per attempt and rebuilds the event (fresh created_at + random nip04 IV -> different event id each time). If attempt 1 is actually accepted but its OK doesn't arrive within CONFIRM_TIMEOUT (30s over a fresh circuit is plausible), attempts 2/3 post additional copies -> multiple Output/Psbt messages from one npub. All copies carry the same deterministic signature over the same outpoint, so there is no double-spend, but peers must dedup them by address/outpoint. This is inherent at-least-once behavior; flagging so it's a conscious trade.

Worth confirming (not a blocker)

  • Peers dedup duplicate registrations by address/outpoint (see the Low item).

Also minor, pre-existing: deserialize_hex(...).unwrap() in listen_txs (electrum.rs, already marked // TODO: do not unwrap) panics the listen thread on malformed server hex.

Nothing set a read timeout on the electrum socket: the ssl client restores the default None after its handshake and socks5::connect clears the handshake deadline before returning. BATCH_TIMEOUT is only evaluated between completed reads, so a server that answers some batch ids and then holds the socket open without a trailing newline parked read_line in a blocking read forever; the same applied to get_tx and broadcast. The coinjoin worker wedged and no terminal update ever reached the caller. Set an actual socket read timeout on every connect path; hitting it surfaces as a retryable error so with_reconnect rebuilds the circuit.
broadcast runs through with_reconnect, so a transaction that reached the server and was accepted but whose reply desynced gets rebroadcast; the server then answers 'already known', which mapped to BroadcastRejected. The round was reported failed with final_tx unset even though its transaction was on the network. Recognize the already-known wordings from Core, electrs and ElectrumX and return success instead, with tests pinning both directions.
@1440000bytes

Copy link
Copy Markdown
Author

Nostr-pubkey linkability is acceptable per the threat model: IP unlinkability is restored, but both the output and input registration are signed by the same keys (prepare_output/prepare_input in joinstr/mod.rs), so the relay/coordinator can still associate the two by sender pubkey.

No. It cannot link anything when everything is registered in the channel with the same key.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
rust/joinstr/src/electrum.rs (1)

386-389: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make BatchTimeout retryable.

The batch path returns Error::BatchTimeout, but Error::is_retryable() only matches Electrum and WrongResponse. Therefore with_reconnect() returns this timeout without reconnecting, so a partially stalled connection can still fail scanning instead of recovering.

🔧 Suggested fix
-        matches!(self, Error::Electrum(_) | Error::WrongResponse)
+        matches!(
+            self,
+            Error::Electrum(_) | Error::WrongResponse | Error::BatchTimeout
+        )

Add a regression test covering the timeout/reconnect path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/electrum.rs` around lines 386 - 389, Update
Error::is_retryable() to classify Error::BatchTimeout as retryable alongside
Electrum and WrongResponse, ensuring with_reconnect() reconnects after batch
timeouts. Add a regression test covering a BatchTimeout that triggers reconnect
and retry behavior.
dart/rust/src/api/types.rs (1)

298-306: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve Step::Failed in the FFI mapping.

Line 306 converts Step::Failed to Other, so FfiCoinjoinUpdate::progress emits an ambiguous update even though the FFI contract has an explicit Failed state. Map only Unconfigured and Configured to Other.

Proposed fix
-            Step::Unconfigured | Step::Configured | Step::Failed => FfiCoinjoinStep::Other,
+            Step::Unconfigured | Step::Configured => FfiCoinjoinStep::Other,
+            Step::Failed => FfiCoinjoinStep::Failed,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dart/rust/src/api/types.rs` around lines 298 - 306, Update the From<Step>
implementation for FfiCoinjoinStep so Step::Failed maps to the explicit
FfiCoinjoinStep::Failed variant, while only Step::Unconfigured and
Step::Configured map to Other.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/joinstr/src/electrum.rs`:
- Around line 80-85: Update is_already_known to remove the broad
m.contains("duplicate") condition and recognize only specific already-known
transaction phrases or error codes, while preserving the existing already/known,
mempool, and chain matching. Add negative coverage for messages such as
"duplicate inputs" to ensure rejected transactions are not reported as
successful broadcasts.

---

Outside diff comments:
In `@dart/rust/src/api/types.rs`:
- Around line 298-306: Update the From<Step> implementation for FfiCoinjoinStep
so Step::Failed maps to the explicit FfiCoinjoinStep::Failed variant, while only
Step::Unconfigured and Step::Configured map to Other.

In `@rust/joinstr/src/electrum.rs`:
- Around line 386-389: Update Error::is_retryable() to classify
Error::BatchTimeout as retryable alongside Electrum and WrongResponse, ensuring
with_reconnect() reconnects after batch timeouts. Add a regression test covering
a BatchTimeout that triggers reconnect and retry behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86325dc6-f3fd-4e1c-9e87-44de26c30166

📥 Commits

Reviewing files that changed from the base of the PR and between c4925da and 61ddef9.

📒 Files selected for processing (4)
  • dart/rust/src/api/joinstr.rs
  • dart/rust/src/api/types.rs
  • rust/joinstr/src/electrum.rs
  • rust/joinstr/src/interface.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • rust/joinstr/src/interface.rs
  • dart/rust/src/api/joinstr.rs

Comment on lines +80 to +85
fn is_already_known(message: &str) -> bool {
let m = message.to_ascii_lowercase();
m.contains("already") && (m.contains("known") || m.contains("mempool") || m.contains("chain"))
|| m.contains("duplicate")
|| m.contains("txn-already-in-mempool")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not treat every duplicate rejection as an already-known transaction.

The bare m.contains("duplicate") branch also classifies unrelated validation failures such as "duplicate inputs" as successful broadcasts. That can report coinjoin completion even though the transaction was rejected. Match specific already-known phrases/codes and add negative coverage.

🔧 Suggested fix
-        || m.contains("duplicate")
+        || m.contains("duplicate transaction")
         for msg in [
             "min relay fee not met",
+            "duplicate inputs",
             "bad-txns-inputs-missingorspent",

Also applies to: 980-1014

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/joinstr/src/electrum.rs` around lines 80 - 85, Update is_already_known
to remove the broad m.contains("duplicate") condition and recognize only
specific already-known transaction phrases or error codes, while preserving the
existing already/known, mempool, and chain matching. Add negative coverage for
messages such as "duplicate inputs" to ensure rejected transactions are not
reported as successful broadcasts.

@kwsantiago

kwsantiago commented Jul 26, 2026

Copy link
Copy Markdown

You're right, and I'll retract the linkability note. I traced it against the NIP and the code: join_pool (rust/joinstr/src/joinstr/mod.rs:459-476) swaps the peer's throwaway auth key for the pool's shared keypair on receiving Credentials { id, private_key }, then every output/input registration is published under that one shared pool key. So all peers post under one identical pubkey, leaving nothing per-peer to correlate. My "same keys" observation was true but the conclusion was backwards, since the key is pool-wide, not peer-specific. Confirmed NIP-compliant: kind:2022 for the pool event and NIP-04 (kind 4) for the DMs, per NIP.md.

The socket-read-timeout blocker and the "successful broadcast reported as failure" item are independent of the key model and still stand.

@1440000bytes

Copy link
Copy Markdown
Author

The socket-read-timeout blocker and the "successful broadcast reported as failure" item are independent of the key model and still stand.

Already fixed in 32a1bdc and 61ddef9

@kwsantiago kwsantiago left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 61ddef9

@kwsantiago
kwsantiago merged commit 6e4e2e3 into master Jul 26, 2026
7 checks passed
@kwsantiago
kwsantiago deleted the pr-coinjoin-fixes branch July 26, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants