Skip to content

fix: On-chain send returns before broadcast result #112

Description

@ovitrif

Summary

OnchainPayment::send_to_address and send_all_to_address return a Txid after placing the transaction in an asynchronous broadcast queue, before the configured backend attempts the broadcast. Backend rejection and timeout results are only logged, so mobile callers cannot distinguish local transaction submission from backend acceptance.

This causes synonymdev/bitkit-ios#717 and synonymdev/bitkit-android#1211: both apps immediately present the returned Txid as a successful on-chain send.

Reproduced against 0.7.0-rc.63 (c9a41458a646324c6c8728b4de8329ad4d52ac8d). The same queue-based result contract remains on main (03ccac798aeb907c6dda367fa4c8af6e3eaa54fa).

Reproduction

  1. Sync an Electrum-backed regtest wallet against a chain at height 160,623.
  2. Point the same wallet at a different regtest chain near height 1,114.
  3. Call send_to_address.
  4. The wallet creates a transaction with nLockTime=160623 and returns its Txid.
  5. The backend rejects the transaction as non-final; it never enters the mempool.

Observed result: the caller receives Ok(txid) and both mobile apps show success even though the backend dropped the transaction.

Root cause

Wallet::send_to_address invokes the synchronous BroadcasterInterface, computes the transaction id, and returns it:

self.broadcaster.broadcast_transactions(&[&tx]);
let txid = tx.compute_txid();
Ok(txid)

TransactionBroadcaster::broadcast_transactions only enqueues cloned transactions with try_send. ChainSource::continuously_process_broadcast_queue later submits each package to Electrum, Esplora, or bitcoind. Those backend results cannot reach the original caller.

The Electrum path also misreports backend rejection as success. spawn_blocking returns a nested Result, but the current match treats every successfully joined task as success without inspecting the inner transaction_broadcast result:

Ok(res) => match res {
    Ok(_) => log_trace!(..., "Successfully broadcast transaction {}", txid),
    Err(e) => ...
}

Here Ok(_) includes Ok(Err(electrum_rejection)) from the joined task.

Expected behavior

The on-chain send API exposes an outcome that distinguishes local creation/submission from backend acceptance. A deterministic backend rejection such as non-final reaches the caller or an equivalent transaction-keyed event before the caller is expected to present success.

Accepted transactions retain the current Txid behavior. Asynchronous rebroadcasting for LDK-managed transactions can remain independent of the explicit user-send result.

Consumer evidence

  • iOS calls sendToAddress / sendAllToAddress and immediately creates sent activity plus the success screen from the returned Txid.
  • Android calls the same rc.63 binding and likewise completes the send flow from the returned Txid.
  • Neither binding exposes the raw transaction or a backend broadcast-result event, so the apps cannot safely recover the missing result without duplicating chain-source behavior.

Activity

  1. self-assigned this
    on Sep 1, 2026
  2. changed the title [-]On-chain send returns before backend broadcast result[/-] [+]fix: On-chain send returns before broadcast result[/+] on Sep 7, 2026
  3. ovitrif commented on Sep 24, 2026

    @ovitrif
    CollaboratorAuthor

    The reorg recovery finding showed that persisting only the queue event was insufficient: after the wallet committed the new chain state, ordinary sync could no longer reproduce a lost transition.

    Implemented in 07c1c19 via PR113: pending reorgs now share the wallet chain's atomic persistence record, with per-account sequence receipts retained until source acknowledgement. This permits normal-sync recovery without replaying an old BDK event and preserves reconfirmation across restart. The change is tested and pushed, not merged; current-head review and rc.67 publication remain outstanding.

  4. ovitrif commented on Sep 24, 2026

    @ovitrif
    CollaboratorAuthor

    The follow-up review found that a reorg-cleanup storage error could return before processing unrelated incoming transaction notifications, even though their wallet update was already persisted and would not emit those events again.

    The fix in 2db646f keeps reorg retries under the durable journal while consuming the ordinary event batch before returning a reorg error. Confirmations for transactions with pending reorgs stay behind their reorg event. A regression covers received and confirmed incoming transactions across four reorg failure points and restart without replaying old BDK events.

    Implemented in the PR, not merged. Fresh current-commit approval and separate rc.67 publication authorization remain required.

  5. ovitrif commented on Sep 30, 2026

    @ovitrif
    CollaboratorAuthor

    The replacement in #119 uses separate fixed-amount and send-all methods returning Accepted, Rejected or Unknown with the locally computed txid. The legacy queued send API remains available. The closed #113 recovery design is historical; transaction journals, RBF recovery, abandonment and reorg redesign are outside this replacement.

    Backend acceptance is an acknowledgement of this exact transaction, not confirmation or propagation. Refusal or uncertainty never authorizes constructing another payment. Apps persist a bounded attempt guard before dispatch and finish local activity/proof only after acceptance or independent observation of the exact transaction. A lost result can still leave the attempt blocked indefinitely; there is no durable node outcome lookup or exactly-once guarantee across restored backups/devices.

    Paired integrations:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions