(NB, this is the leading hypothesis for the remaining flake noted in #1976, i.e. "missing anchor block hash, transfer not confirmed." The actual bug itself appears to be in chain porter's error handling; the "primary" and "tertiary" potential fixes IMO look most promising.)
(EDIT: this has been ruled out as the cause of #1976.)
Failure
CI run 25216477057, branch mssmt-unit-db-backends @
657fd71. Both htlc_force_close and htlc_force_close_mpp
fail with:
missing anchor block hash, transfer not confirmed
at locateAssetTransfers for Alice's final second-level HTLC
success sweep (helpers.go:3155; CI shows :3173 due to line
drift on the CI branch). The transfer row exists; the block
hash does not. The test polls for 2 minutes and times out.
This is NOT the timeout-sweep mempool/RBF flake fixed by
3d8d534. That failure occurs later in the test; this one
fires in the HTLC success path before timeout sweeps begin.
Observed State
ListTransfers returns exactly one transfer for the mined
sweep txid.
AnchorTxBlockHash is nil for the full poll window.
- The predecessor sweep (first-level HTLC success tx) was
already confirmed: locateAssetTransfers at
helpers.go:3122 succeeded, meaning storeProofs and
LogAnchorTxConfirm both completed for it.
Transfer Lifecycle
StorePreBroadcast LogPendingParcel — row written
Broadcast PublishTransaction
WaitTxConf deliverOutboundPkgResp — caller unblocked
waitForTransferTxConf — blocks for conf
StorePostAnchorTxConf storeProofs
storePackageAnchorTxConf
→ LogAnchorTxConfirm — block hash written
The row exists but the block hash was never written. The state
machine stopped advancing somewhere between
deliverOutboundPkgResp and LogAnchorTxConfirm.
Root Cause
Structural bug: silent post-delivery error
The porter delivers the response to the caller in WaitTxConf
(chain_porter.go:2096) before doing the confirmation wait
and post-confirmation work. RequestShipment returns on
respChan; NotifyBroadcast returns to lnd's sweeper. From
that point the porter goroutine is unsupervised.
Any subsequent error goes to errChan, which nobody reads:
// chain_porter.go:380-384
updatedPkg, err := p.stateStep(*pkg)
if err != nil {
kit.errChan <- err // buffered(1), no reader
log.Errorf(...)
return // goroutine exits
}
The error is logged at ERROR level in tapd's daemon logs (not
captured in CI test output). The goroutine exits. The parcel
stays pending. LogAnchorTxConfirm never runs.
resumePendingParcels would rescue it on restart, but tests
don't restart tapd.
This is the decisive structural bug. Without it, any transient
failure would propagate to the caller and the sweeper would
retry.
Likely trigger: confirmation/finalization failure
The specific error that fires within the silent-failure window
is not directly observable from the CI test logs (only tapd
ERROR logs would show it). The candidates, ranked:
-
Confirmation not delivered. The porter registers for
confirmation via RegisterConfirmationsNtfn with a height
hint captured before broadcast (aux_sweeper.go:2579).
The registration includes a historical rescan, which should
catch already-confirmed txs. But there is a known class of
race in chain notifiers: if the confirming block is
processed between the scan completing and the watch being
installed, the notification is lost. This race is narrow
but widens under CI load (many parallel tests, rapid block
mining). Commit eea18cf ("add sweep shipment height
hint") was a targeted mitigation for this area, confirming
it was already known to be timing-sensitive.
-
storeProofs or storePackageAnchorTxConf failure.
If confirmation IS delivered but a subsequent step fails
(proof fetch, verification, DB update), the same
silent-error path strands the parcel.
-
Broadcast state failure / stale parcel. The porter
redundantly calls PublishTransaction for sweep txs that
lnd also broadcasts. A transient gRPC failure could leave
an orphaned stored-but-unprocessed parcel. RBF handling
is unimplemented (aux_sweeper.go:2397 TODO). Less likely
for this specific run (single broadcast, valid tx, no
fee-bumping).
Demoted hypothesis: proof archive race
An earlier hypothesis proposed that storeProofs for the
second-level sweep fails because fetchInputProof can't find
the first-level sweep's proof (not yet archived). This was
ruled out: locateAssetTransfers already succeeded for the
first-level sweep at helpers.go:3122, which requires both
storeProofs and LogAnchorTxConfirm to have completed. The
predecessor proof IS archived before the second-level sweep
begins. This remains a valid class of failure but is not the
trigger here.
Why Only the Second-Level Sweep
The second-level sweep is uniquely vulnerable:
- Longest dependency chain (commitment → first-level →
second-level), so the most intermediate porter state to
traverse before confirmation registration.
- Occurs latest in the test, after the most blocks have been
mined, giving the most opportunity for scheduling jitter.
assertSweepExists only confirms the sweep input is
registered with the sweeper, not that the sweep tx has
been broadcast or that the porter has registered for
confirmation. The test may mine the confirming block before
the porter reaches RegisterConfirmationsNtfn.
Fix
Primary: make post-delivery failures recoverable
The silent-error pattern is the structural enabler. Fix it:
-
Retry in-process. When waitForTransferTxConf,
storeProofs, or storePackageAnchorTxConf fails after
deliverOutboundPkgResp, retry with backoff rather than
exiting.
-
Background recovery. Periodically check for parcels
whose anchor tx is confirmed on-chain but whose block hash
is nil. Re-enter StorePostAnchorTxConf for them. This
covers both "confirmation never delivered" (the tx IS
confirmed, the porter just didn't hear) and "post-
confirmation processing failed." Also provides production
resilience without requiring a daemon restart.
Secondary: skip broadcast for sweep parcels
Set skipAnchorTxBroadcast=true in shipChannelTxn
(aux_closer.go:682). Infrastructure already exists. Since
lnd's sweeper handles broadcasting, the porter's
PublishTransaction is redundant. Skipping it eliminates the
Broadcast failure mode and shortens the window between parcel
storage and confirmation registration. Requires moving
importLocalAddresses to before the skip check in
StorePreBroadcast.
Tertiary: tighten confirmation registration
Register for confirmation earlier in the pipeline (e.g., in
StorePreBroadcast rather than after deliverOutboundPkgResp
in WaitTxConf), so the watch is active before the tx is
published and the race window is minimised.
Key Files
| File |
Line |
Role |
tapfreighter/chain_porter.go |
380 |
advanceState — unread errChan |
tapfreighter/chain_porter.go |
416 |
waitForTransferTxConf |
tapfreighter/chain_porter.go |
886 |
LogAnchorTxConfirm |
tapfreighter/chain_porter.go |
2013 |
LogPendingParcel |
tapfreighter/chain_porter.go |
2024 |
SkipAnchorTxBroadcast check |
tapfreighter/chain_porter.go |
2042 |
Broadcast — redundant publish |
tapfreighter/chain_porter.go |
2096 |
deliverOutboundPkgResp |
tapfreighter/chain_porter.go |
2104 |
StorePostAnchorTxConf |
tapchannel/aux_sweeper.go |
2393 |
registerAndBroadcastSweep |
tapchannel/aux_sweeper.go |
2575 |
height hint (best-effort) |
tapchannel/aux_closer.go |
682 |
skipAnchorTxBroadcast=false |
helpers.go |
3122 |
predecessor sweep confirmed OK |
helpers.go |
3155 |
failing call |
(NB, this is the leading hypothesis for the remaining flake noted in #1976, i.e. "missing anchor block hash, transfer not confirmed." The actual bug itself appears to be in chain porter's error handling; the "primary" and "tertiary" potential fixes IMO look most promising.)
(EDIT: this has been ruled out as the cause of #1976.)
Failure
CI run 25216477057, branch
mssmt-unit-db-backends@657fd71. Both
htlc_force_closeandhtlc_force_close_mppfail with:
at
locateAssetTransfersfor Alice's final second-level HTLCsuccess sweep (
helpers.go:3155; CI shows:3173due to linedrift on the CI branch). The transfer row exists; the block
hash does not. The test polls for 2 minutes and times out.
This is NOT the timeout-sweep mempool/RBF flake fixed by
3d8d534. That failure occurs later in the test; this one
fires in the HTLC success path before timeout sweeps begin.
Observed State
ListTransfersreturns exactly one transfer for the minedsweep txid.
AnchorTxBlockHashis nil for the full poll window.already confirmed:
locateAssetTransfersathelpers.go:3122succeeded, meaningstoreProofsandLogAnchorTxConfirmboth completed for it.Transfer Lifecycle
The row exists but the block hash was never written. The state
machine stopped advancing somewhere between
deliverOutboundPkgRespandLogAnchorTxConfirm.Root Cause
Structural bug: silent post-delivery error
The porter delivers the response to the caller in
WaitTxConf(
chain_porter.go:2096) before doing the confirmation waitand post-confirmation work.
RequestShipmentreturns onrespChan;NotifyBroadcastreturns to lnd's sweeper. Fromthat point the porter goroutine is unsupervised.
Any subsequent error goes to
errChan, which nobody reads:The error is logged at ERROR level in tapd's daemon logs (not
captured in CI test output). The goroutine exits. The parcel
stays pending.
LogAnchorTxConfirmnever runs.resumePendingParcelswould rescue it on restart, but testsdon't restart tapd.
This is the decisive structural bug. Without it, any transient
failure would propagate to the caller and the sweeper would
retry.
Likely trigger: confirmation/finalization failure
The specific error that fires within the silent-failure window
is not directly observable from the CI test logs (only tapd
ERROR logs would show it). The candidates, ranked:
Confirmation not delivered. The porter registers for
confirmation via
RegisterConfirmationsNtfnwith a heighthint captured before broadcast (
aux_sweeper.go:2579).The registration includes a historical rescan, which should
catch already-confirmed txs. But there is a known class of
race in chain notifiers: if the confirming block is
processed between the scan completing and the watch being
installed, the notification is lost. This race is narrow
but widens under CI load (many parallel tests, rapid block
mining). Commit eea18cf ("add sweep shipment height
hint") was a targeted mitigation for this area, confirming
it was already known to be timing-sensitive.
storeProofsorstorePackageAnchorTxConffailure.If confirmation IS delivered but a subsequent step fails
(proof fetch, verification, DB update), the same
silent-error path strands the parcel.
Broadcast state failure / stale parcel. The porter
redundantly calls
PublishTransactionfor sweep txs thatlnd also broadcasts. A transient gRPC failure could leave
an orphaned stored-but-unprocessed parcel. RBF handling
is unimplemented (
aux_sweeper.go:2397TODO). Less likelyfor this specific run (single broadcast, valid tx, no
fee-bumping).
Demoted hypothesis: proof archive race
An earlier hypothesis proposed that
storeProofsfor thesecond-level sweep fails because
fetchInputProofcan't findthe first-level sweep's proof (not yet archived). This was
ruled out:
locateAssetTransfersalready succeeded for thefirst-level sweep at
helpers.go:3122, which requires bothstoreProofsandLogAnchorTxConfirmto have completed. Thepredecessor proof IS archived before the second-level sweep
begins. This remains a valid class of failure but is not the
trigger here.
Why Only the Second-Level Sweep
The second-level sweep is uniquely vulnerable:
second-level), so the most intermediate porter state to
traverse before confirmation registration.
mined, giving the most opportunity for scheduling jitter.
assertSweepExistsonly confirms the sweep input isregistered with the sweeper, not that the sweep tx has
been broadcast or that the porter has registered for
confirmation. The test may mine the confirming block before
the porter reaches
RegisterConfirmationsNtfn.Fix
Primary: make post-delivery failures recoverable
The silent-error pattern is the structural enabler. Fix it:
Retry in-process. When
waitForTransferTxConf,storeProofs, orstorePackageAnchorTxConffails afterdeliverOutboundPkgResp, retry with backoff rather thanexiting.
Background recovery. Periodically check for parcels
whose anchor tx is confirmed on-chain but whose block hash
is nil. Re-enter
StorePostAnchorTxConffor them. Thiscovers both "confirmation never delivered" (the tx IS
confirmed, the porter just didn't hear) and "post-
confirmation processing failed." Also provides production
resilience without requiring a daemon restart.
Secondary: skip broadcast for sweep parcels
Set
skipAnchorTxBroadcast=trueinshipChannelTxn(
aux_closer.go:682). Infrastructure already exists. Sincelnd's sweeper handles broadcasting, the porter's
PublishTransactionis redundant. Skipping it eliminates theBroadcast failure mode and shortens the window between parcel
storage and confirmation registration. Requires moving
importLocalAddressesto before the skip check inStorePreBroadcast.Tertiary: tighten confirmation registration
Register for confirmation earlier in the pipeline (e.g., in
StorePreBroadcastrather than afterdeliverOutboundPkgRespin
WaitTxConf), so the watch is active before the tx ispublished and the race window is minimised.
Key Files
tapfreighter/chain_porter.goadvanceState— unreaderrChantapfreighter/chain_porter.gowaitForTransferTxConftapfreighter/chain_porter.goLogAnchorTxConfirmtapfreighter/chain_porter.goLogPendingParceltapfreighter/chain_porter.goSkipAnchorTxBroadcastchecktapfreighter/chain_porter.goBroadcast— redundant publishtapfreighter/chain_porter.godeliverOutboundPkgResptapfreighter/chain_porter.goStorePostAnchorTxConftapchannel/aux_sweeper.goregisterAndBroadcastSweeptapchannel/aux_sweeper.gotapchannel/aux_closer.goskipAnchorTxBroadcast=falsehelpers.gohelpers.go