Skip to content

txconfirm: reusable tx confirmation actor with CPFP (3/5) - #262

Merged
Roasbeef merged 17 commits into
mainfrom
unroll-03-txconfirm
Apr 22, 2026
Merged

txconfirm: reusable tx confirmation actor with CPFP (3/5)#262
Roasbeef merged 17 commits into
mainfrom
unroll-03-txconfirm

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

Summary

Part 3 of 5 in the stacked split of #235. Adds txconfirm: a
generic shared actor that deduplicates confirmation requests by txid
and ensures transactions confirm on-chain. Any subsystem that needs
"get this tx confirmed" can use it — not tied to unrolling.

  • 9fc7359txconfirm: add reusable tx confirmation actor with CPFP

Features:

  • Automatic anchor detection and CPFP child construction
  • Package relay with individual-broadcast fallback
  • Periodic fee bumping (non-terminal on failure: the original
    broadcast is still live and the confirmation watch remains active,
    so the next bump waits the full interval before retrying)
  • Subscriber fan-out for deduped confirmation/failure notifications
  • protofsm-based lifecycle:
    New → Broadcasting → AwaitingConfirmation → (FeeBumping loop) → Confirmed/Failed

Key types: TxBroadcasterActor, CPFPBroadcaster, EnsureConfirmedReq.

Depends on the SubmitPackage chain-backend surface added in the
prep PR (#260).

Forward-port from the original branch

Imports of lib/scripts were migrated to lib/arkscript (matching
main's removal of the legacy helper package). The fixup was folded
back into Elle's original commit.

Stack

# Branch PR Scope
1/5 unroll-01-prep #260 preparatory fixes + infra
2/5 unroll-02-plan #261 lib/recovery + unrollplan
3/5 unroll-03-txconfirm this PR txconfirm actor
4/5 unroll-04-core (to come) vtxo + db + rpc + unroll/
5/5 unroll-05-wire (to come) daemon wiring + CLI

Supersedes #235.

Authorship

Commit authored by @ellemouton.

Test plan

  • go test ./txconfirm/...
  • go vet ./...
  • go build ./cmd/...
  • CI: full unit + lint

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request adds the txconfirm package, featuring a generic actor and a CPFP-capable broadcaster for managing transaction confirmations. The implementation uses a state machine to track transaction lifecycles. Key feedback identifies critical issues: deduplication by txid fails to account for differing confirmation targets or scripts, terminal entries are not pruned from memory, and synchronous polling in the broadcaster blocks the actor's execution. Furthermore, a logic error in the polling exit condition could lead to premature broadcast failures.

Comment thread txconfirm/actor.go
Comment on lines +259 to +263
if existing, ok := a.tracked[txid]; ok {
return a.attachExistingSubscriber(
ctx, existing, req.Subscriber,
), nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Deduplication by txid alone is insufficient and can lead to incorrect behavior when subscribers have different requirements. For example, if a transaction is already tracked with TargetConfs=1, a new request for the same txid requiring TargetConfs=6 will be attached to the existing entry and notified prematurely at 1 confirmation. Similarly, if requests specify different ConfirmationPkScript values, they will be incorrectly merged. The actor should either use a more specific key for the tracked map (e.g., including the target and script) or update the existing entry to satisfy the most stringent requirements (e.g., the maximum TargetConfs).

Comment thread txconfirm/actor.go
Comment on lines +353 to +355
if state == TxStateConfirmed || state == TxStateFailed {
return resp, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The tracked map is never pruned of terminal entries, leading to a memory leak. In handleCancel, the logic explicitly returns early if the transaction is in a terminal state (TxStateConfirmed or TxStateFailed), which prevents the entry from being deleted from a.tracked even when no subscribers remain. Additionally, notifyConfirmed and notifyFailed remove subscribers from the entry but do not remove the entry from the actor's map. Over time, this map will grow indefinitely as more transactions are processed.

if state == TxStateConfirmed || state == TxStateFailed {
		delete(a.tracked, entry.data.Txid)
		resp.StoppedTracking = true
		return resp, nil
	}

Comment thread txconfirm/broadcaster.go
Comment on lines +334 to +385
deadline := time.Now().Add(2 * time.Second)

for {
utxos, err := b.cfg.Wallet.ListUnspent(ctx, 1, 9999999)
if err != nil {
return nil, fmt.Errorf("list unspent: %w", err)
}

var best *wallet.Utxo
for _, utxo := range utxos {
_, excluded := b.usedFeeOutpoints[utxo.Outpoint]
if excluded {
continue
}

if utxo.Amount < minAmount {
continue
}

if best == nil || utxo.Amount < best.Amount {
best = utxo
}
}

if best != nil {
return &FeeInput{
Outpoint: best.Outpoint,
Output: &wire.TxOut{
Value: int64(best.Amount),
PkScript: best.PkScript,
},
Confirmed: true,
}, nil
}

// After one CPFP package confirms, wallet backends
// with asynchronous chain ingestion can lag briefly
// before the confirmed change output becomes visible.
// Poll through that handoff instead of immediately
// falling back to direct broadcast of a zero-fee
// parent.
if len(b.usedFeeOutpoints) == 0 || time.Now().After(deadline) {
break
}

select {
case <-ctx.Done():
return nil, ctx.Err()

case <-time.After(100 * time.Millisecond):
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The selectFeeInput method performs synchronous polling with a 2-second deadline inside the actor's Receive loop. This blocks the actor from processing any other messages, including block updates or new confirmation requests, for the duration of the poll. This can cause significant latency and potential mailbox overflows if multiple transactions require fee bumps simultaneously. A non-blocking approach, such as scheduling a retry message to the actor itself or making the UTXO selection asynchronous, would be more appropriate for a shared service.

Comment thread txconfirm/broadcaster.go
Comment on lines +374 to +376
// parent.
if len(b.usedFeeOutpoints) == 0 || time.Now().After(deadline) {
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The condition len(b.usedFeeOutpoints) == 0 causes the polling loop to exit immediately if no other transactions have been bumped in the current block. This defeats the stated purpose of the polling (handling wallet lag after a block) for the first transaction processed in a new block. If the wallet backend is lagging, the first bump attempt will fail immediately instead of waiting for the confirmed UTXOs to become visible.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b47f73975

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread txconfirm/actor.go
Comment on lines +670 to +673
ctx, &chainsource.UnregisterConfRequest{
CallerID: a.confCallerID(entry.data.Txid),
Txid: &txid,
TargetConfs: entry.data.TargetConfs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include pkScript when unregistering confirmation watches

This unregister request omits PkScript, but registerConfWatch always registers with both Txid and PkScript. In chainsource.txidOrScriptKey, that means registration uses a different service key (txid+script) than unregistration (txid), so the confirmation actor is never found and never stopped. The leak happens for every tracked tx and leaves stale confirmation subscriptions/goroutines behind after confirm/cancel/shutdown paths.

Useful? React with 👍 / 👎.

Comment thread txconfirm/broadcaster.go Outdated
Comment on lines +208 to +210
b.usedFeeOutpoints[feeInput.Outpoint] = struct{}{}

changePkScript, err := b.deriveChangePkScript(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Release fee-input reservation when CPFP setup aborts

The fee outpoint is marked as used before CPFP construction/signing/submission succeeds, and several later branches (deriveChangePkScript, child build/sign, package submit errors, or direct-fallback paths) can return without spending that UTXO. Because usedFeeOutpoints is only cleared on a new block, subsequent submissions in the same block can incorrectly treat a still-unspent coin as unavailable and fail with ErrCPFPFeeInputUnavailable.

Useful? React with 👍 / 👎.

@levmi levmi added the actor label Apr 20, 2026
@Roasbeef
Roasbeef force-pushed the unroll-02-plan branch 2 times, most recently from c0731c8 to 9e35a2d Compare April 22, 2026 00:08
@Roasbeef
Roasbeef force-pushed the unroll-03-txconfirm branch from 3b47f73 to 4f3aaba Compare April 22, 2026 00:39
@Roasbeef
Roasbeef changed the base branch from unroll-02-plan to main April 22, 2026 00:39
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@codex review this

@claude

claude Bot commented Apr 22, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 3m 24s —— View job


Code Review — txconfirm: reusable tx confirmation actor with CPFP (3/5)

The overall design is clean: protofsm lifecycle, sealed message interfaces, actor-serialized state, and good test coverage. There are several bugs in this PR that need to be addressed before merge, though — a confirmed subscription leak, a memory leak, and a race-with-yourself in the CPFP fee input reservation. The Gemini/Codex reviews already caught most of them; this review confirms them with line-level analysis and adds a few more.


Critical

1. Missing PkScript in unregisterConfWatch causes permanent subscription leak

registerConfWatch sends both Txid and PkScript, which causes chainsource.txidOrScriptKey to generate the compound key "<txid>+script:<hex>". unregisterConfWatch sends only Txid, generating plain "<txid>". These keys never match, so the underlying conf-actor is never stopped — goroutines, channels, and the chain backend subscription all leak for every tracked tx.

// actor.go:664 — missing PkScript
_, err := a.cfg.ChainSource.Ask(
    ctx, &chainsource.UnregisterConfRequest{
        CallerID:    a.confCallerID(entry.data.Txid),
        Txid:        &txid,
        TargetConfs: entry.data.TargetConfs,
        // PkScript: entry.data.ConfirmationPkScript  ← MISSING
    },
).Await(ctx).Unpack()

Fix: add PkScript: append([]byte(nil), entry.data.ConfirmationPkScript...) to the request, mirroring the register call. The test double fakeChainSourceRef doesn't use the key for lookup, so tests pass despite the bug. Fix this →


2. Terminal entries never pruned from a.tracked — memory leak

Both notifyConfirmed and notifyFailed clear entry.subscribers but never call delete(a.tracked, entry.data.Txid). And handleCancel (actor.go:353) returns early without deleting when the state is already terminal:

// actor.go:353 — entry stays in map forever after terminal state
if state == TxStateConfirmed || state == TxStateFailed {
    return resp, nil  // entry.data.Txid still in a.tracked
}

Additionally, entries that fail in handleEnsure during ensureBlockSubscription or registerConfWatch are added to the map (line 273) before the failure occurs and are never removed.

Fix for handleConfirmationObserved — after notifyConfirmed:

a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs)
delete(a.tracked, msg.txid)

Fix for handleCancel terminal early-return:

if state == TxStateConfirmed || state == TxStateFailed {
    delete(a.tracked, entry.data.Txid)
    resp.StoppedTracking = true
    return resp, nil
}

Fix for failTrackedTx when called from handleEnsure:

a.failTrackedTx(ctx, entry, fmt.Sprintf("broadcast: %v", err))
delete(a.tracked, txid)

3. Fee input reservation not released when CPFP setup aborts

usedFeeOutpoints[feeInput.Outpoint] is set (broadcaster.go:208) before any CPFP construction succeeds. If deriveChangePkScript, BuildCPFPChild, signCPFPChild, or SubmitPackage fails, fallbackDirectBroadcast is called — the fee UTXO is never spent but it remains excluded from the next bump in the same block. If there is only one suitable UTXO, subsequent bumps will always see ErrCPFPFeeInputUnavailable until the next block.

Fix: mark the outpoint used only after the package is successfully submitted (or individual broadcast fallback succeeds), or clear it in the fallback path:

func (b *CPFPBroadcaster) fallbackDirectBroadcast(...) (*BroadcastResult, error) {
    delete(b.usedFeeOutpoints, feeInput.Outpoint) // reclaim unused input
    ...
}

High Priority

4. Synchronous polling inside Receive blocks the entire actor

selectFeeInput (broadcaster.go:334–385) spins with time.After(100ms) for up to 2 seconds inside the actor's Receive call. Every block notification, new EnsureConfirmedReq, and cancel request queues up behind this loop. With many unconfirmed transactions requiring simultaneous fee bumps, the actor's mailbox can overflow.

5. Poll exit condition defeats its own purpose

// broadcaster.go:375
if len(b.usedFeeOutpoints) == 0 || time.Now().After(deadline) {
    break
}

This breaks immediately when usedFeeOutpoints is empty — exactly the case when we're trying to fee-bump a transaction for the first time after a new block. The comment says the poll exists to handle "wallet lag after a block," but the first bump in every block always exits without polling. The condition should be time.Now().After(deadline) alone (or checked first), relying solely on the 2-second deadline.


Medium Priority

6. Deduplication by txid alone can mis-fire for different TargetConfs

If subscriber A registers with TargetConfs=1 and subscriber B registers later with TargetConfs=6 for the same txid, B is attached to the existing entry (actor.go:259–262) and will be notified at 1 confirmation — not 6. Similarly, a different ConfirmationPkScript would register one watch for A's script but notify B on the same event.

This may be acceptable if callers are expected to use consistent parameters for the same txid, but the API contract doesn't document or enforce it. At minimum, add a warning log when attaching a subscriber whose parameters differ from the existing entry's.

7. BumpCount increments on failed fee-bump recovery

When a fee-bump fails, handleBlockObserved (actor.go:436–443) sends a recovery trackedTxBroadcastAccepted with a zeroed-out BumpCount. trackedTxStateFeeBumping.ProcessEvent then does progress.BumpCount = s.BumpCount + 1 — so a failed bump still counts. This makes BumpCount an unreliable indicator of successful rebroadcasts.


Minor / Style

8. trackedTxErrorReporter.ReportError uses error log level

Per CLAUDE.md: "error log level is only for internal bugs, never external triggers." An unexpected FSM event, while always a programming error in this package, could originate from an external chain event racing with a cancel. WarnS or at least a comment explaining why Error is correct here would be consistent with the project convention.

9. Synthetic EnsureConfirmedResp for internal messages

// actor.go:176
case *blockEpochObservedMsg:
    a.handleBlockObserved(ctx, req)
    return fn.Ok[Resp](&EnsureConfirmedResp{
        State: TxStateAwaitingConfirmation,
    })

Internal messages are delivered via Tell, so this response is never consumed. A zero-valued Txid and hard-coded TxStateAwaitingConfirmation is misleading. Consider a dedicated internal response type or a clearly-named sentinel value.

10. isPackageSubmissionUnsupported uses strings.Contains

strings.Contains(err.Error(), "not supported") is fragile — any error from the call stack containing "not supported" will be misclassified as an unsupported-package-relay error. A sentinel error var or a specific substring match would be safer.


Test coverage gaps

  • No test for the unregisterConfWatch PkScript mismatch (issue lib: start adding lib helpers #1) — the fake chainsource doesn't validate the key at all.
  • No test that a.tracked is eventually pruned after confirmation or failure (issue multI: add initial repo scaffolding #2).
  • No test for the fee-input reservation retention on fallback (issue chainbackend+chainsource: add chainsource actor and backend impl #3).
  • TestCancelInterestStopsTracking at line 525 checks unregisterConfCount() == 1 but doesn't verify that a subsequent block epoch after cancel does NOT trigger a re-broadcast, which is what it actually tests (it does — line 554 — but no assertion on the absence of a second unregister).

Summary: Issues #1 and #2 are data-loss/leak bugs that will affect production. Issue #3 is a logic bug that can silently exhaust available UTXOs within a block. Issues #4 and #5 together mean the CPFP polling mechanism doesn't work as intended. The rest are lower severity. The FSM design and test structure are solid; these bugs are fixable without architectural changes.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f3aaba1a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread txconfirm/actor.go
Comment on lines +258 to +262
txid := req.Tx.TxHash()
if existing, ok := a.tracked[txid]; ok {
return a.attachExistingSubscriber(
ctx, existing, req.Subscriber,
), nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor requested confirmation targets on duplicate ensures

This dedup path reuses an existing tracked entry solely by txid, so later requests with a different TargetConfs are silently attached to the first request’s watch. In practice, a subscriber asking for 6 confs can be notified at 1 conf (or vice versa), because registerConfWatch is never reconciled to the new target. That breaks the EnsureConfirmedReq contract that each request is confirmed to its requested depth.

Useful? React with 👍 / 👎.

Comment thread txconfirm/broadcaster.go Outdated
)
}

b.usedFeeOutpoints[feeInput.Outpoint] = struct{}{}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reserve CPFP fee outpoint only after successful CPFP submit

The fee input is marked as used before CPFP construction/signing/submission has succeeded. If any later step falls back to direct broadcast or returns an error, the outpoint remains excluded for the rest of the block height even though no child spent it. That can cause subsequent anchor submissions at the same height to hit ErrCPFPFeeInputUnavailable and skip otherwise valid broadcasts.

Useful? React with 👍 / 👎.

Comment thread txconfirm/actor.go
Comment on lines +312 to +314
a.failTrackedTx(ctx, entry, fmt.Sprintf(
"broadcast: %v", err,
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Unregister confirmation watch when initial broadcast fails

This failure path marks the tracked tx as terminally failed but does not unregister the confirmation subscription that was created earlier in handleEnsure. That leaves a live conf watch for a txid the actor no longer processes, consuming chainsource resources and producing callbacks that are ignored due to the failed state.

Useful? React with 👍 / 👎.

Comment thread txconfirm/actor.go
return false
}

return a.bestHeight-lastBroadcastHeight >=

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should abstract this further, as it can eventually use the lnd fee function. https://github.com/lightningnetwork/lnd/blob/a8a3e13120ebd52050ea4c6b653e8182f62fbae9/sweep/fee_function.go#L120

sweep.NewLinearFeeFunction. It takes into account the step, target, max, and then uses a linear function to bump progressively as needed.

Comment thread txconfirm/actor.go

// notifyOneFailed delivers one terminal failure notification.
func (a *TxBroadcasterActor) notifyOneFailed(ctx context.Context,
subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nice pattern here re an abstract actor for subscriptions!

Comment thread txconfirm/broadcaster.go Outdated
// ChildVSizeEstimate is the estimated virtual size of a CPFP child with
// one anchor input, one confirmed fee input, and a single change
// output. Revisit this estimate if the child input composition changes.
ChildVSizeEstimate = 155

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We can use the actual fee estimator here instead: input.TxWeightEstimator.

Comment thread txconfirm/broadcaster.go
Output *wire.TxOut

// Confirmed indicates whether this UTXO is confirmed.
Confirmed bool

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

👍

Comment thread txconfirm/broadcaster.go
}

// BroadcastRequest describes a signed transaction to broadcast.
type BroadcastRequest struct {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Would think this accepts a package? Or the idea is that we always spend an anchor if it's there.

Comment thread txconfirm/broadcaster.go
if err != nil {
return nil, fmt.Errorf("list unspent: %w", err)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should be exposing a LeaseOutput method via an interface to call once we finally select the input: https://github.com/lightningnetwork/lnd/blob/a8a3e13120ebd52050ea4c6b653e8182f62fbae9/lnrpc/walletrpc/walletkit.proto#L39-L53.

We'd then need to unlock then as needed as well.

Only the lnd+btcwallet backends need this, it'll be a noop for lwallet for now (can make an issue abouve that).

Comment thread txconfirm/broadcaster.go Outdated

// EstimatePackageFee computes the total package fee for one parent+child
// submission at the given fee rate.
func EstimatePackageFee(parentTx *wire.MsgTx,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We can use input.TxWeight estimator here again.

@Roasbeef
Roasbeef force-pushed the unroll-03-txconfirm branch from 5398bf5 to 88df42b Compare April 22, 2026 02:52
ellemouton and others added 12 commits April 21, 2026 19:53
Generic shared actor that deduplicates confirmation requests by txid
and ensures transactions confirm on-chain. Any subsystem that needs
"get this tx confirmed" can use it.

Features:
- Automatic anchor detection and CPFP child construction
- Package relay with individual-broadcast fallback
- Periodic fee bumping (non-terminal on failure so the original
  broadcast can still confirm via the active watch)
- Subscriber fan-out for deduped confirmation/failure notifications
- protofsm-based lifecycle: New → Broadcasting → AwaitingConfirmation
  → (FeeBumping loop) → Confirmed/Failed

Key types: TxBroadcasterActor, CPFPBroadcaster, EnsureConfirmedReq.
Addresses two lifecycle-correctness bugs found in the PR 262 review of
the new txconfirm actor.

H-1: terminal a.tracked entries were never evicted. Once a tracked txid
reached Confirmed or Failed, its *trackedTx (with a cached *wire.MsgTx
and a live per-tx FSM goroutine) stayed in the map until actor shutdown.
A long-lived daemon accumulated an O(total_txs_ever) leak even when
otherwise idle. handleConfirmationObserved and failTrackedTx now call a
new evictTerminal helper that unregisters any outstanding conf watch,
stops the FSM, and drops the entry. The previous replay-on-late-subscribe
path in attachExistingSubscriber is no longer reachable via dedup: a late
ensure for a previously-confirmed txid starts fresh tracking, which
re-registers with chainsource and — for already-confirmed txs — receives
an immediate confirmation notification through the normal path.

H-8: chainsource derives a conf sub-actor's service key by hashing
CallerID + Txid + PkScript + TargetConfs (via txidOrScriptKey in
chainsource.go). RegisterConfRequest in this package passed all four
fields, producing the key "<txid>+script:<hex>". UnregisterConfRequest
omitted PkScript, producing a different key "<txid>" — so the unregister
silently found nothing and leaked one conf sub-actor per tracked tx. The
existing test fake did not model service-key derivation so the bug went
uncaught. unregisterConfWatch now passes PkScript alongside Txid,
matching the register payload.

A confWatchRegistered flag on trackedTx records whether a watch is
currently live so evictTerminal knows whether an unregister round trip
is even needed (entries that failed during block-subscription setup
never registered).

Regression tests:
  - TestTerminalEntriesEvictedAfterConfirmation drives three
    unrelated txids end-to-end and uses Cancel-as-probe to assert the
    entries are no longer present.
  - TestTerminalEntryEvictedAfterFailure does the same for the broadcast
    error path.
  - TestUnregisterConfMatchesRegisterServiceKey asserts every service-key
    input (CallerID, Txid, PkScript, TargetConfs) matches byte-for-byte
    between the register and unregister requests.
  - TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath and
    TestEnsureConfirmedBroadcastFailureNotifiesFailure are updated to
    reflect that late subscribers now open fresh tracking.
Addresses H-6 from the PR 262 review. signCPFPChild wrote WitnessUtxo
values into packet.Inputs[0] and [1] by positional index, assuming the
anchor sat at index 0 and the fee input at index 1 — an invariant
upheld only by BuildCPFPChild's current construction. The post-finalize
witness copy loop iterated finalTx.TxIn with the same positional
assumption and would panic with an index-out-of-range if the wallet
returned a transaction whose input composition did not exactly
round-trip the PSBT (added or reordered inputs, which some backends do
as part of BIP 69 canonicalization or internal bookkeeping).

The function now takes the anchor outpoint as an explicit parameter and
locates the anchor / fee slots in the PSBT by matching each input's
PreviousOutPoint. Witness copy-back builds an outpoint-keyed map over
finalTx.TxIn and copies into the corresponding child.TxIn slot, making
reordered finalized transactions transparently correct. A length
mismatch or an outpoint substitution produces a clean error routed
through the existing fallbackDirectBroadcast path rather than a panic
or a malformed package submission.

A new rewritingWallet test double parses the incoming PSBT, applies
dummy witnesses, and lets the test rewrite the finalized tx before
returning. TestSignCPFPChildHandlesWalletInputRewrites exercises three
cases: reordered inputs (succeeds), extra inputs (length mismatch →
fallback), and substituted outpoint (missing key → fallback).
Phase 1 of the CPFP-correctness fixes from the PR 262 review. txconfirm
is intentionally subsystem-neutral, but the CPFP fee-bump strategy and
anchor-detection heuristic both assume BIP-431 (TRUC / v3) semantics:

  - The zero-fee ephemeral anchor only relays as part of a v3 package.
  - Pattern-based anchor detection (findAnchorOutput → arktx.IsAnchorOutput
    which matches P2A script bytes) is structurally unsafe for a non-v3
    parent whose outputs were chosen without anchor-awareness; a
    legitimate output that happens to look like an anyone-can-spend
    anchor would silently receive a CPFP child that never relays and
    burns the caller's wallet fee input.
  - Package RBF replacement policy (the landing mechanism for our
    fee-bump loop) requires TRUC topology.

CPFPBroadcaster.Submit now rejects any parent whose Version is not
arktx.TxVersion (= 3) with a new sentinel ErrNonTRUCParent before doing
any chainsource work. TestCPFPBroadcasterFallbackAndErrors gains a
"non-v3 parent rejected at Submit" sub-test that drives the rejection
and asserts via errors.Is.

This closes the API-boundary gap that was the root cause of review
finding H-5 (pattern-based anchor detection) and a precondition for
Phase 2/3 (bump comparator and per-parent outpoint reservation) to
have well-defined semantics.
Phase 2 of the CPFP-correctness fixes from the PR 262 review. The
fee-bump loop submitted a fresh anchor + child package every
FeeBumpIntervalBlocks blocks, but the new package's fee was computed
from scratch against the current estimator on each cycle with no
comparison to the package it was meant to replace. Two concrete
regressions followed:

  - Flat estimator. Every bump regenerated a byte-identical package at
    the same feerate and same absolute fee. Core rejected each cycle as
    "already in mempool" / "already known"; the ignorable-error list
    even classified that as non-fatal so the log silently papered over
    the fact that we were making zero forward progress.
  - Dipping estimator. When fee estimates dropped between bumps (blocks
    with less competition, or a backend that rolls window averages),
    the new package paid a strictly lower feerate than the original.
    Core's package RBF rejected the replacement and the tx sat with
    whatever fee it had at the previous cycle.

CPFPBroadcaster now maintains a per-parent-txid parentBumpState
recording LastFeeRate and LastPackageFee. Every broadcastWithCPFP
invocation runs the fresh estimator output through applyReplacementFloor
before selecting a fee input:

  - Rule 4: if the new feerate is <= the prior feerate, ratchet it to
    prior + 1 sat/vB.
  - Rule 3: if the resulting absolute fee doesn't exceed the prior
    package fee by at least IncrementalRelayFeeSatPerVByte *
    packageVSize, top it up to do so.

A new BroadcasterConfig.IncrementalRelayFeeSatPerVByte (defaulting to
DefaultIncrementalRelayFeeSatPerVByte = 1 sat/vB, matching Bitcoin
Core's default) lets operators with non-default nodes pin their own
incremental relay feerate. The same knob is exposed on the actor's
Config and forwarded through.

CPFPBroadcaster.Evict(txid) releases the per-parent state; the actor's
evictTerminal now calls it alongside its own tracked-entry cleanup so
parentStates doesn't accumulate after H-1's fix is applied.

Regression tests:
  - TestApplyReplacementFloor is a white-box unit test hitting every
    branch of the comparator (no prior state, flat estimator, dipping
    estimator, Rule 3 domination via custom IRF, caller-provided larger
    totalFee preserved).
  - TestCPFPBroadcasterFeeBumpReplacementFloor drives the full
    broadcaster end-to-end across three submissions under flat and
    dipping estimators and asserts strictly-increasing feerate and
    absolute fee across bumps, plus Evict clearing the per-parent
    history.
Phase 3 of the CPFP-correctness fixes from the PR 262 review. The
broadcaster previously tracked fee-input reservations in a global
usedFeeOutpoints map cleared on every new block. Two concrete bugs
followed:

  - A parent still waiting for its CPFP package to confirm would lose
    its reservation on the very next block, and an unrelated concurrent
    parent could then pick the same wallet UTXO for its own fee input,
    double-spending the UTXO across two in-flight children.
  - The same parent's next fee-bump cycle (cleared by the block
    advance) would re-select the same UTXO — which, without BIP-125
    signaling on v1/v2 parents, could not RBF the first child. Phase 1
    gates non-v3 parents at Submit so this isn't a consensus foot gun
    anymore, but TRUC package RBF *wants* the new child to spend the
    same fee UTXO as the previous child (that is how the replacement
    lands), so the reservation has to stay scoped to the parent while
    excluding every *other* parent.

UsedFeeOutpoints now lives on parentBumpState alongside the fee-history
fields. selectFeeInput receives the parent txid and builds its
exclusion set from outpoints held by parents other than the current
one. The per-block clear is removed entirely; Evict is the single
release point, called when the actor's FSM learns the parent has
terminally confirmed or failed.

Regression test TestUsedFeeOutpointsKeyedByParent exercises:
  - Reservation persisting across a new block for the same parent.
  - A second parent being blocked from reusing the first parent's UTXO.
  - Evict releasing the UTXO for other parents.
  - The same parent being allowed to re-pick a UTXO it already
    reserved (the TRUC package-RBF replacement path).
Phase 4 of the CPFP-correctness fixes from the PR 262 review. The CPFP
child's fee input now carries the BIP-125 replaceable sequence number
(wire.MaxTxInSequenceNum - 2 = 0xfffffffd) instead of the sentinel
MaxTxInSequenceNum. The anchor input keeps its sentinel sequence
because it is anyone-can-spend with no timelock semantics.

This is a belt-and-suspenders fix on top of Phase 1 (Submit now rejects
non-v3 parents), so in practice the parent tx's v3 version already
implies replaceability for TRUC package RBF. The explicit sequence
signal means that if a future caller ever slips past the version gate
with a v2 parent (for example by bypassing the helper), the child
would still be eligible for BIP-125 replacement on non-TRUC relays
instead of being permanently pinned.

The existing "build cpfp child" test now asserts the sequence layout
so any future change to BuildCPFPChild must either preserve this
property or explicitly update the test.
Bitcoin Core's testmempoolaccept RPC has accepted an array of hex
transactions since 24.0, so the full-package testmempoolaccept flow
can preflight an entire CPFP parent+child set without broadcasting
either transaction first. The ChainBackend interface only exposed the
single-tx form, which makes it impossible for callers like txconfirm's
CPFPBroadcaster to preflight a package against local node policy
before submission.

Change the interface to accept a variadic *wire.MsgTx and return a
slice of MempoolAcceptResult (one per input tx, in request order). The
new ErrPackageMempoolAcceptUnsupported sentinel lets implementations
that cannot evaluate multi-tx packages distinguish "backend refused to
validate at all" from "backend evaluated and one tx was rejected".

TestMempoolAcceptRequest.Tx becomes TestMempoolAcceptRequest.Txs and
TestMempoolAcceptResponse.{Accepted,Reason} becomes
TestMempoolAcceptResponse.Results []MempoolAcceptResult, matching the
per-tx return shape. handleTestMempoolAccept and the internal
BroadcastTx fallback path (which uses TestMempoolAccept as a
post-broadcast-error sanity check) are updated accordingly.

All three existing ChainBackend implementations (btcwbackend,
chainbackends/lnd, lwwallet) preserve their previous behaviour: none
supports testmempoolaccept today, so each returns an error regardless
of how many txs are supplied. Real single- and package-support can
land in a follow-up that wires lwwallet's ChainBackend through to the
existing EsploraClient.TestMempoolAccept (which already accepts
multiple txs). Test mocks (mockBackend, broadcastErrorBackend,
errorBackend) and the three tests that constructed the old
request/response shape are updated to the new API.

This commit does not yet add a CPFPBroadcaster-side caller; wiring
txconfirm's preflight onto the new interface is the next step.
Phase 5 of the CPFP-correctness fixes from the PR 262 review. With the
new variadic chainsource.ChainBackend.TestMempoolAccept interface in
place, wire CPFPBroadcaster to preflight every broadcast attempt
against the local node's mempool policy before handing the transaction
(or parent+child package) off to the network.

Preflight exposes three observable outcomes:

  - Accepted: submission proceeds.
  - Rejected with reason: submission aborts with an error that
    includes the backend's reject reason and the rejected txid.
    Callers surface this to the FSM rather than waste a network round
    trip on a package we know the mempool will drop.
  - Backend doesn't implement testmempoolaccept: downgraded to a soft
    miss (debug log) and submission proceeds. This keeps the flag safe
    to leave enabled on btcwbackend, chainbackends/lnd, and
    lwwallet deployments today, while still gaining real preflight
    checks on backends that do support the RPC.

The check is opt-in via BroadcasterConfig.PreSubmitTestMempoolAccept,
defaulting off. The same knob is plumbed through the actor Config so
callers of TxBroadcasterActor can enable preflight without reaching
into the internal broadcaster.

broadcastDirect preflights the single tx before BroadcastTx;
broadcastWithCPFP preflights parent+child together after the child is
signed but before SubmitPackage. The CPFP path deliberately preflights
after signing so a rejection caught here represents a policy-level
rather than construction-level failure.

TestPreflightTestMempoolAccept covers: CPFP path preflights
parent+child together; direct path preflights only the single tx;
backend rejection surfaces the reason and aborts before broadcast;
"not supported" is treated as a soft-miss and lets submission
through; preflight is disabled by default.
Expand doc.go into a full literate overview of the package. The new
doc walks through the Architecture / Lifecycle / CPFP correctness /
PSBT finalization / Service-key round trip / Eviction story so a
reader landing here for the first time can understand both WHAT each
primitive does and WHY each invariant exists, linking back to the
five CPFP-correctness phases this branch added.

Also expand a handful of core godocs that were previously terse:

  - TxBroadcasterActor now spells out the single-threaded-Receive,
    one-watch-per-non-terminal-entry, no-terminal-entries, and
    lazy-block-subscription invariants.
  - trackedTx notes that mutation is actor-goroutine-exclusive and
    therefore not mutex-guarded.
  - handleBlockObserved explains why fee-bump failures are
    non-terminal — the original broadcast and confirmation watch are
    still live, and the recovery path lets the next interval
    retry rather than escalating to Failed.

No functional change.
Defines wallet.LockID ([32]byte) and wallet.OutputLeaser with
LeaseOutput/ReleaseOutput methods that match the canonical shape used
by btcwallet and lndclient's WalletKit. Concrete backends
(btcwbackend, lndbackend, lwwallet) now satisfy the interface by
forwarding to the underlying btcwallet or LND WalletKit.

txconfirm.Wallet embeds OutputLeaser so every CPFP fee-input selected
by the broadcaster takes a wallet-level lease on reservation and
releases it on terminal eviction or fallback. This closes a narrow
race where a wallet shared across subsystems could hand the same UTXO
to another caller in between CPFP child construction and broadcast.
The broadcaster's per-parent parentStates map remains the source of
truth; the wallet lease is defence in depth, and lease errors are
treated as soft misses so a noop-leaser wallet is still functional.
Introduces per-package documentation for the new txconfirm package
(broadcast + CPFP fee-bump + confirmation notification actor) and
adds it to ARCHITECTURE.md's Layer 2 infrastructure table. Covers
key types, chainsource / wallet message flows, and the CPFP
correctness invariants that future wiring must preserve.
@Roasbeef
Roasbeef force-pushed the unroll-03-txconfirm branch from 88df42b to 269fb2b Compare April 22, 2026 02:53
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@codex review this

@claude

claude Bot commented Apr 22, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 4m 47s —— View job


Code Review — txconfirm: reusable tx confirmation actor with CPFP (3/5)

Tasks

  • Read context and understand request
  • Read txconfirm/actor.go
  • Read txconfirm/broadcaster.go
  • Read txconfirm/messages.go, states.go, fsm_types.go
  • Read wallet/interfaces.go, test double in actor_test.go
  • Compile and post review

This is a re-review of the current HEAD (4f3aaba). The first-pass review found several critical bugs; I verified which have been addressed and which remain.

Fixed Since the First Review ✓

The four critical/high bugs from the prior Claude + Codex reviews are resolved in the current code:

  1. unregisterConfWatch PkScript symmetry — now sends the full PkScript and TargetConfs (actor.go:783–805) with a doc comment explaining the key-derivation invariant.
  2. Terminal entry eviction / memory leakevictTerminal (actor.go:917–940) is called from every terminal path: failTrackedTx (line 893), handleConfirmationObserved (line 467), and handleCancel (line 426). evictTerminal also calls a.broadcaster.Evict to release broadcaster-side state.
  3. Fee input reservation on CPFP abortreleaseFeeOutpoint is correctly called in all fallback/failure paths: fallbackDirectBroadcast (line 796), preflight reject (line 665), individual-broadcast failure (line 689), and wholesale package error (line 703).
  4. Deduplication by txid alonevalidateEnsureMatch (actor.go:633–654) now rejects mismatched TargetConfs or ConfirmationPkScript with ErrEnsureParamsMismatch instead of silently mis-attaching.

Remaining Issues

Medium

1. Synchronous 2-second poll inside Receive blocks the actor mailbox

selectFeeInput (broadcaster.go:857–910) spins with time.After(100ms) for up to 2 seconds inside the actor's Receive call. Every block notification, new EnsureConfirmedReq, and cancel request queues behind this loop. With N unconfirmed CPFP parents triggering simultaneous fee bumps, the actor stalls for up to N × 2s.

The fix is to not poll synchronously. The simplest approach: if no UTXO is found after the first pass, return ErrCPFPFeeInputUnavailable immediately. The actor's non-terminal handling of this error in handleBlockObserved already means the next block epoch will retry — that's effectively a ~10-minute retry interval, far more appropriate than an in-band 2s spin. The polling comment says it handles "wallet lag after a block", but that lag is bounded by the block-epoch delivery delay, not by the broadcaster's spin.

Minor

2. BumpCount increments on failed fee-bumps

When a fee-bump fails, handleBlockObserved (actor.go:505–513) sends trackedTxBroadcastAccepted with zeroed progress to recover the FSM from FeeBumping back to AwaitingConfirmation. trackedTxStateFeeBumping.ProcessEvent (states.go:194–195) unconditionally does progress.BumpCount = s.BumpCount + 1, so every failed bump increments the counter. BumpCount is documented as counting "successful fee-bump rebroadcasts" but it includes failures.

Fix: either rename to BumpAttemptCount to match actual semantics, or pass the old BumpCount through in the recovery message instead of relying on the FSM state to increment it:

// in handleBlockObserved error recovery:
oldProgress := currentFSMProgress(entry)  // read BumpCount before recovery
progress := trackedTxProgress{
    LastBroadcastHeight: a.bestHeight,
    BumpCount: oldProgress.BumpCount,  // do not increment on failure
}

3. isPackageSubmissionUnsupported uses strings.Contains

broadcaster.go:1334. Any error whose .Error() string contains "not supported" anywhere in the chain — including errors from unrelated code layers — is misclassified as "backend doesn't support package relay." A sentinel error variable or a specific substring (e.g. "package submission not supported") would be safer. At minimum this matches the same fragility as IsIgnorableBroadcastError but that one uses multiple well-known broadcast error strings; this one is a single generic substring.

4. EstimateFeeRate uses Warnf instead of WarnS

broadcaster.go:817:

b.log.Warnf("Fee estimation failed, using fallback "+
    "%d sat/vB: %v", minFeeRateSatPerVByte, err)

Per CLAUDE.md / docs/structured-logging.md: structured logging with WarnS and key-value pairs is required. Should be:

b.log.WarnS(ctx, "Fee estimation failed, using fallback rate",
    err, "fallback_rate_sat_vb", minFeeRateSatPerVByte)

5. handleCancel terminal branch doesn't delete map entry

actor.go:413–415:

if state == TxStateConfirmed || state == TxStateFailed {
    return resp, nil  // entry stays in a.tracked
}

The TxBroadcasterActor invariant says a.tracked never holds terminal entries (evictTerminal is called before the message loop continues), so in practice this branch should be unreachable. If that invariant ever breaks, the entry silently leaks. Adding delete(a.tracked, entry.data.Txid) before the return costs nothing and makes the function safe by construction rather than by invariant.


Design Feedback (Roasbeef's Comments)

sweep.NewLinearFeeFunction: The current strategy increments feerate by exactly +1 sat/vB per bump (via applyReplacementFloor). This is the minimum BIP-125 Rule 4 compliance but doesn't accelerate the transaction as the deadline approaches. Wiring in sweep.NewLinearFeeFunction (with configurable step, target-block deadline, and max feerate) would give callers a proper deadline-driven fee schedule.

LeaseOutput interface: Already wired in the current code — Wallet embeds wallet.OutputLeaser (broadcaster.go:112), and reserveFeeOutpoint / releaseWalletLease call LeaseOutput / ReleaseOutput with txconfirmLockID. Roasbeef's comment is addressed.


Test Coverage Note

The fake chainsource (actor_test.go:188–194) still keyed by *req.Txid alone for confNotify. The production code is correct (PkScript symmetry is now enforced), but the fake won't catch a future regression that drops PkScript again. Worth extending UnregisterConfRequest handling in the fake to validate the compound key.


Summary: The four bugs flagged in the first review are fixed. The remaining items are one medium-severity actor-mailbox blocking issue (selectFeeInput poll), three minor correctness/style issues, and a latent defensive-code gap. The FSM design, eviction logic, BIP-125 floor arithmetic, and PSBT finalization are clean.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 269fb2bfcc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread txconfirm/actor.go
Comment on lines +423 to +427
entry.fsm.Stop()
}

delete(a.tracked, entry.data.Txid)
resp.StoppedTracking = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Evict fee-input leases when canceling final interest

When CancelInterestReq removes the last subscriber, this path stops the FSM and deletes the tracker but never calls a.broadcaster.Evict(...). If the tx previously reserved a CPFP fee input, that wallet lease stays active until expiry (default 1 hour), so later broadcasts can be starved of UTXOs even though tracking was explicitly canceled. This is observable for anchor transactions that reached broadcastWithCPFP before cancellation.

Useful? React with 👍 / 👎.

Comment thread txconfirm/actor.go
Comment on lines +286 to +289
if entry.fsm != nil {
entry.fsm.Stop()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Evict fee-input leases during actor shutdown

OnStop unregisters watches and stops FSMs but does not evict broadcaster state for active tracked txs. As a result, CPFP fee-input leases are not proactively released on shutdown; with backends that persist leases across restarts, those outpoints remain locked until lease timeout and can block unrelated wallet coin selection after restart.

Useful? React with 👍 / 👎.

handleCancel (last subscriber) and OnStop (active tracked txs) now
invoke a.broadcaster.Evict(ctx, txid) so any wallet-level fee-input
lease taken during broadcastWithCPFP is released immediately rather
than lingering until the wallet's expiry. Without this, cancelling a
subscription or restarting the daemon with in-flight CPFP parents
left UTXOs locked in backends that persist leases across restarts
(btcwallet, lndclient WalletKit), starving unrelated wallet
coin-selection paths.

Adds two regression tests that assert every recorded LeaseOutput has
a matching ReleaseOutput under the package-scoped txconfirmLockID.
@Roasbeef
Roasbeef merged commit 76773dc into main Apr 22, 2026
16 checks passed
ellemouton pushed a commit that referenced this pull request May 22, 2026
Extend the ledger actor's UTXO diff subsystem so every wallet
movement gets a definitive classification and gets booked as
either a round / sweep attribution (no external leg) or an
operator external_deposit / external_withdrawal -- the piece
that was left at 'audit-only' in PR #201.

Rather than stand up a separate attribution table, piggyback on
the existing wallet_utxo_log:

  * New source_id BYTEA NULL column carries round_id / batch_id
    when a round or sweep handler pre-inserts an attributed row.
  * New classifications: withdrawal (spent-side deposit),
    sweep_consumption (spent-side sweep_return), round_change
    (naming parity with round_funding), and pending (the
    two-phase limbo the diff loop uses before reconciliation).
  * New sqlc helpers PromotePendingWalletUTXOLog (atomic flip
    of stale pending rows into deposit/withdrawal) and
    InsertWalletUTXOLog now carries source_id and returns the
    rowcount so the diff loop can tell a genuine insert from a
    silent no-op against an already-attributed row.

Classifier loop lives in two passes on each BlockEpochMsg:

  1. reconcilePendingAuditRows promotes any pending row left
     behind by the previous block's diff to deposit or
     withdrawal and books the matching external_* ledger leg
     via fees.RecordExternalDeposit / RecordExternalWithdrawal.
  2. applyUTXODiff inserts the current block's diff rows as
     pending; pre-inserts from round / sweep handlers
     short-circuit via the UNIQUE (hash, index, event)
     constraint so the classifier never double-books.

The one-block grace window covers the narrow race where a
BlockEpochMsg lands on the ledger actor's mailbox before the
matching RoundConfirmedMsg / SweepCompletedMsg from a
simultaneously-confirmed round or sweep.

Follow-up commits wire the round / sweep producers to populate
the new TLV outpoint slices and the handlers to pre-insert the
attributed audit rows.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants