Skip to content

Implement HTLC operation enhancements and error handling improvements - #94

Open
sbn20241 wants to merge 2 commits into
htlc_preparationfrom
htlc_crash_save
Open

sbn20241 wants to merge 2 commits into
htlc_preparationfrom
htlc_crash_save

Conversation

@sbn20241

@sbn20241 sbn20241 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

This PR is separated from htlc_preparation branch, cause its creates to much diffs, main goal of this pr to fix these issues - #65 (review)

@sbn20241
sbn20241 requested review from Jainakin and txalkan September 4, 2026 16:26

@Jainakin Jainakin 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.

I reviewed the exact head 201f3e00 against htlc_preparation and rechecked it against the production blockers raised on #65.

This PR contains meaningful improvements: payloads are hash-bound and durably replaced, consignment paths are Windows-safe, missing PSBTs fail closed, the UDA panic is converted to a typed error, and the focused witness_receive accounting path now has end-to-end coverage. The 18 focused HTLC tests pass locally.

I am still requesting changes because the recovery protocol remains unsafe at several externally observable boundaries:

  1. Generic fail_transfers can still fail an HTLC after broadcast was marked Attempted. I reproduced this deterministically by adding mark_broadcast -> fail_transfers_single to the existing broadcast test; fail_transfers_single returned true and failed the batch.
  2. The new broadcast/adoption APIs are Rust-only. The Go/UniFFI caller can invoke htlc_abort, but cannot durably mark a broadcast attempt or adopt the operation.
  3. Txid lookup does not recover a lost htlc_prepare response, because the caller does not know the colored txid when that response is lost. A caller-known idempotency/operation key is required.
  4. The marker ordering still depends on a non-atomic, non-fsynced three-file RGB stock write; it is not evidence that the preceding stock generation is durably coherent.
  5. A crash between publishing Prepared metadata and committing the SQL batch leaves an orphan operation that cannot be completed and can make later txid adoption ambiguous.
  6. Corrupt operation metadata is silently omitted during scans, turning corruption into false not found results and allowing SQL failure to proceed without coherent healing.
  7. Broadcast/reconcile metadata transitions are not acknowledged by VSS. Device loss can restore stale NotAttempted evidence after a real broadcast.

The operation store also has no retention/pruning policy, and bulk failure still performs serial indexer lookups. Concurrent mark/abort/apply/fail operations need explicit serialization or compare-and-set semantics.

Validation on the exact head:

  • focused HTLC tests: 18/18 passed;
  • injected broadcast/fail regression: failed at the intended safety assertion;
  • all-features Clippy: passed;
  • no-default-features Clippy: failed on the unconditional Hash/sha256 import;
  • repository rustfmt check: failed;
  • GitHub checks: none are registered for this stacked target branch.

Please keep #65's crash-consistency review open until these boundaries are closed and covered by deterministic crash/concurrency tests.

Comment thread src/wallet/rust_only.rs
if !runtime.stash_contains_fascia_witness(&fascia) {
runtime.consume_fascia(fascia, None)?;
}
runtime.persist()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking: this does not yet establish the durable ordering claimed by the helper. RgbRuntime::persist() delegates to rgb-ops Stock::store(), which writes stash, state, and index as three independent stores (the dependency itself has a "revert on failure" TODO). Those files are not committed as one atomic generation, and the stock path does not provide the fsync/rename barrier that the marker path provides. A process kill or I/O failure inside any one store can therefore leave partial stock; the test hook runs only after all three stores have returned. Please introduce a recoverable/atomic stock-generation protocol, or a journal that can deterministically repair every partial-store boundary, and cover the individual persistence boundaries with process-kill tests before treating the marker as proof of durable stock consumption.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed: persist() is Stock::store() — three independent files, no atomic generation, upstream TODO: Revert on failure. The marker only means persist() returned Ok. I’ll spell that out in the helper rustdoc.

An atomic stock protocol and process-kill tests at each stash/state/index boundary belong in rgb-ops, not in this HTLC helper. The same store() path is already used by send/accept/issue; rgb-lib cannot inject faults between those writes without forking the dependency.

This PR covers the rgb-lib window: persist under the runtime lock, fsync the marker, skip a second consume if the stash already has the witness. Partial stock inside store() is the existing rgb-ops TODO — we’d track that upstream rather than block claim/refund on it.

Comment thread src/wallet/rust_only.rs Outdated
if Self::validate_htlc_operation_id(&name).is_err() {
continue;
}
if let Ok(meta) = self.htlc_read_meta(&name) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fail closed on recognized operation-directory corruption. This silently drops every htlc_read_meta error. As a result, a corrupt or partially durable meta.json becomes indistinguishable from "operation not found"; htlc_get_by_txid can return a false NotFound, and fail_transfers_finish can complete SQL failure/backup without healing or quarantining the corresponding operation. Return the inconsistency, or persist a typed quarantine record that callers must resolve. Please add malformed, truncated, and missing-meta scan tests.

Comment thread src/wallet/rust_only.rs Outdated
None,
),
};
self.htlc_write_meta(&operation_id, &meta)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is still a prepare crash window here. Prepared metadata is published before the SQL batch exists. The error cleanup below only handles a returned Err; a process/power loss after this write leaves an apparently adoptable operation with no linked batch, so htlc_apply cannot complete it. Retrying prepare can publish a second operation for the same colored txid and make txid lookup ambiguous. Please model Preparing -> Prepared explicitly and reconcile/rollback the pre-commit intent idempotently, or atomically publish the operation only after the batch is recoverably committed. Add kill tests on both sides of the SQL commit and batch-identity write.

Comment thread src/wallet/online.rs
self.check_online(online)?;
let txn = self.database().begin_transaction()?;
let outcome =
self.fail_transfers_impl(&txn, batch_transfer_idx, no_asset_only, skip_sync)?;

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 generic failure path bypasses the new broadcast-state invariant. fail_transfers_impl does not inspect HTLC metadata before marking the batch failed; healing runs only afterward and blindly mirrors SQL Failed. I reproduced this on the exact head by extending htlc_abort_refused_after_broadcast_attempt: after htlc_mark_broadcast, fail_transfers_single(batch_idx) returned true and failed the transfer. An Attempted, Observed, or Ambiguous HTLC must be rejected/quarantined before the SQL transaction commits, including bulk expiry. Please add this regression for single and bulk failure paths.

Comment thread src/wallet/rust_only.rs Outdated
match meta.broadcast {
HtlcBroadcastState::NotAttempted => {
meta.broadcast = HtlcBroadcastState::Attempted;
self.htlc_write_meta(operation_id, &meta)

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 safety-critical broadcast transition is neither reachable by the required external caller nor remotely durable. This PR adds no UniFFI/UDL method or HtlcBroadcastState binding, while issue #90 states that the HTLC flow is called from Go. It also returns after only the local metadata write and does not trigger, await, or fence a VSS backup. After a real broadcast followed by device loss, restore can regress to NotAttempted and permit rollback. Expose the transition through UniFFI and require durable acknowledgement (or integrate mark+broadcast behind one owned operation) before the caller broadcasts.

Comment thread src/wallet/rust_only.rs Outdated
/// <div class="warning">This method is meant for special usage and is normally not needed, use
/// it only if you know what you're doing</div>
#[cfg(any(feature = "electrum", feature = "esplora"))]
pub fn htlc_get_by_txid(&self, txid: &str) -> Result<HtlcOperation, Error> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Txid is not a reliable adoption key for the response-loss case documented here. The UniFFI caller passes the original PSBT by value; coloring may add/change the RGB commitment and uses random GraphSeal blinding when static_blinding is absent. If htlc_prepare commits and its response is lost, the caller has neither operation_id nor the resulting colored txid, so it cannot call this lookup. This method is also not exposed through UniFFI. Please accept a caller-generated idempotency/operation key known before prepare, make retries return the same committed result, and expose the lookup/result types to the external binding.

Comment thread src/wallet/rust_only.rs
#[cfg(any(feature = "electrum", feature = "esplora"))]
use crate::utils::{recipient_id_from_script_buf, script_buf_from_recipient_id};
use bdk_wallet::bitcoin::Transaction;
use bdk_wallet::bitcoin::hashes::{Hash, sha256};

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 import needs the same electrum || esplora cfg as the code that uses it. On the exact PR head, cargo clippy --lib --workspace --no-default-features -- -D warnings fails because both Hash and sha256 are unused.

@Jainakin Jainakin mentioned this pull request Sep 7, 2026
- Added `stash_contains_fascia_witness` method to check for fascia witness in stash.
- Refactored `fail_transfers` methods in `multisig.rs` and `online.rs` to streamline transaction handling and error management.
- Introduced new methods for handling HTLC operations, including `heal_htlc_ops_after_failed_transfers` and `fail_transfers_commit`.
- Updated `rust_only.rs` with new constants and functions for HTLC consignment paths and SHA256 hashing.
- Enhanced test coverage for HTLC operations, ensuring consistency and error handling during transaction failures.
@sbn20241
sbn20241 force-pushed the htlc_preparation branch 7 times, most recently from a2d3333 to ab9f414 Compare September 10, 2026 09:34
@sbn20241

Copy link
Copy Markdown
Collaborator Author

I reviewed the exact head 201f3e00 against htlc_preparation and rechecked it against the production blockers raised on #65.

This PR contains meaningful improvements: payloads are hash-bound and durably replaced, consignment paths are Windows-safe, missing PSBTs fail closed, the UDA panic is converted to a typed error, and the focused witness_receive accounting path now has end-to-end coverage. The 18 focused HTLC tests pass locally.

I am still requesting changes because the recovery protocol remains unsafe at several externally observable boundaries:

  1. Generic fail_transfers can still fail an HTLC after broadcast was marked Attempted. I reproduced this deterministically by adding mark_broadcast -> fail_transfers_single to the existing broadcast test; fail_transfers_single returned true and failed the batch.
  2. The new broadcast/adoption APIs are Rust-only. The Go/UniFFI caller can invoke htlc_abort, but cannot durably mark a broadcast attempt or adopt the operation.
  3. Txid lookup does not recover a lost htlc_prepare response, because the caller does not know the colored txid when that response is lost. A caller-known idempotency/operation key is required.
  4. The marker ordering still depends on a non-atomic, non-fsynced three-file RGB stock write; it is not evidence that the preceding stock generation is durably coherent.
  5. A crash between publishing Prepared metadata and committing the SQL batch leaves an orphan operation that cannot be completed and can make later txid adoption ambiguous.
  6. Corrupt operation metadata is silently omitted during scans, turning corruption into false not found results and allowing SQL failure to proceed without coherent healing.
  7. Broadcast/reconcile metadata transitions are not acknowledged by VSS. Device loss can restore stale NotAttempted evidence after a real broadcast.

The operation store also has no retention/pruning policy, and bulk failure still performs serial indexer lookups. Concurrent mark/abort/apply/fail operations need explicit serialization or compare-and-set semantics.

Validation on the exact head:

  • focused HTLC tests: 18/18 passed;
  • injected broadcast/fail regression: failed at the intended safety assertion;
  • all-features Clippy: passed;
  • no-default-features Clippy: failed on the unconditional Hash/sha256 import;
  • repository rustfmt check: failed;
  • GitHub checks: none are registered for this stacked target branch.

Please keep #65's crash-consistency review open until these boundaries are closed and covered by deterministic crash/concurrency tests.

  1. Stock/markers/SQL/metadata boundary. Agreed and deferred: the recoverable boundary is a single "stash consumed" flag written in the same DB transaction as the batch status, which is the upcoming DB-backed state model, not a patch to the file layout. Today consume_fascia is idempotent, so a crash before the marker is recovered by repeating apply; the residual window is abort/fail_transfers racing that instant. Recorded as requirement add begin end func #1 for the DB model.

  2. Absent indexer tx / 24h default. expiration_timestamp is now required over UniFFI (u64); the 24h fallback exists only in the Rust API for tests. There is no automatic path from the node — the expiration is an explicit caller statement. After expiration the sweep releasing inputs of a tx the indexer never saw is the documented policy; the stateful "broadcast attempted" protection belongs to the DB model.

  3. Payloads without an integrity-bound manifest. Deferred; requirement Add VSS backup + optional encryption #2 for the DB model (payload published atomically with its readiness flag).

  4. reconcile returning Prepared for Prepared+Failed. Fixed: psbt_op_reconcile now reconciles against the linked batch in both directions (Applied+Settled → Settled, Prepared+Failed → Failed) and persists the result; covers bulk expiry and the backup-snapshot case.

  5. Lost prepare response / no adopt API. Acknowledged, not closed here: list/adopt is a query over operation state and will be built on the DB model rather than on a directory walk we are about to remove. Interim contract: persist operation_id before signing; operation_dir holds colored.psbt, from which the final txid is recoverable. If this blocks your rollout, say so — a psbt_op_list over the current files is ~20 lines of throwaway code.

  6. Duplicate prepare mutating artifacts. Fixed: the BatchTransferAlreadyExists check runs before any write to the transfer directory; a refused call leaves the existing operation untouched.

  7. Receive helper lifecycle. Option two: the helper is a pinned pre-check with early stash import (output pin, confirmations, ExpectedTransfer, and the same validation refresh uses, including BFA events and safe height); the witness_receive SQL/balance/history lifecycle is completed by the ordinary refresh on the same invoice. Documented in the Rust doc, the UDL and the spec. New test psbt_op_foreign_escrow_fetch_and_accept_then_refresh_settles calls the exposed helper against a real witness_receive invoice, then refresh, and asserts final balance, exactly one Receive coloring, settled transfer/history, cleared pending witness script, and the same state after reopening the wallet with the consistency check enabled.

  8. Colon in per-asset filenames. Fixed: consignments/<asset_id with ':' replaced>.rgb.

@sbn20241
sbn20241 force-pushed the htlc_preparation branch 6 times, most recently from 718e29b to 74522b0 Compare September 10, 2026 15:57
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.

3 participants