Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ package may import from a higher layer.
| [`chainsource`](chainsource/) | `ChainBackend` interface: fee estimation, block/conf/spend notifications |
| [`chainbackends`](chainbackends/) | LND-backed `ChainBackend` implementation plus lndclient adapters (`TxBroadcaster`, `PackageSubmitter`) |
| [`chain`](chain/) | Bitcoind RPC utilities (package relay, `SubmitPackage`) |
| [`txconfirm`](txconfirm/) | Generic "broadcast + CPFP fee-bump + notify on confirm" actor with per-parent fee-input reservations and BIP-125 Rule 3/4 enforcement |
| [`lndbackend`](lndbackend/) | `BoardingBackend` implementation via LND's wallet kit |
| [`lwwallet`](lwwallet/) | Lightweight in-process wallet (btcwallet + Esplora, no external LND) |
| [`btcwbackend`](btcwbackend/) | Neutrino-backed wallet backend (btcwallet + compact block filters) |
Expand Down
38 changes: 36 additions & 2 deletions btcwbackend/boarding_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"fmt"
"log/slog"
"math"
"time"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/lightninglabs/darepo-client/wallet"
"github.com/lightninglabs/darepo-client/walletcore"
"github.com/lightninglabs/neutrino"
Expand Down Expand Up @@ -211,6 +213,38 @@ func (b *BoardingBackendAdapter) GetBlock(ctx context.Context,
return block, nil
}

// LeaseOutput forwards the output lock to btcwallet's native
// coin-selection lock table. btcwallet persists leases across
// restarts, so once a caller (e.g. the txconfirm CPFP broadcaster)
// reserves a UTXO via LeaseOutput it stays excluded from coin
// selection even if the daemon restarts mid-bump.
//
// The darepo-local wallet.LockID is re-interpreted as wtxmgr.LockID:
// both are [32]byte, so the translation is a direct type cast rather
// than a mapping table. This keeps the LockID stable across the
// interface boundary so ReleaseOutput can use the same identifier
// without any broker-side state.
func (b *BoardingBackendAdapter) LeaseOutput(_ context.Context,
id wallet.LockID, op wire.OutPoint,
expiry time.Duration) (time.Time, error) {

return b.BtcWallet.LeaseOutput(wtxmgr.LockID(id), op, expiry)
}

// ReleaseOutput forwards the unlock to btcwallet's native
// coin-selection lock table. The supplied LockID must match the one
// used at lease time; mismatches surface as an error from btcwallet
// so misuse fails loudly rather than silently releasing someone
// else's lease.
func (b *BoardingBackendAdapter) ReleaseOutput(_ context.Context,
id wallet.LockID, op wire.OutPoint) error {

return b.BtcWallet.ReleaseOutput(wtxmgr.LockID(id), op)
}

// Compile-time check that BoardingBackendAdapter implements
// wallet.BoardingBackend.
var _ wallet.BoardingBackend = (*BoardingBackendAdapter)(nil)
// wallet.BoardingBackend and wallet.OutputLeaser.
var (
_ wallet.BoardingBackend = (*BoardingBackendAdapter)(nil)
_ wallet.OutputLeaser = (*BoardingBackendAdapter)(nil)
)
4 changes: 2 additions & 2 deletions btcwbackend/chain_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,9 @@ func (b *ChainBackend) BestBlock(ctx context.Context) (int32,
// TestMempoolAccept is not supported by the neutrino backend since
// neutrino does not maintain a mempool.
func (b *ChainBackend) TestMempoolAccept(_ context.Context,
_ *wire.MsgTx) (bool, string, error) {
_ ...*wire.MsgTx) ([]chainsource.MempoolAcceptResult, error) {

return false, "", fmt.Errorf(
return nil, fmt.Errorf(
"test mempool accept not supported by neutrino backend",
)
}
Expand Down
14 changes: 8 additions & 6 deletions chainbackends/lnd.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,18 @@ func (b *LNDBackend) BestBlock(ctx context.Context) (int32, chainhash.Hash,
}
}

// TestMempoolAccept tests whether a transaction would be accepted by the
// mempool. Note that this is not directly supported by lnd's interfaces, so we
// return an error indicating the operation is not supported.
func (b *LNDBackend) TestMempoolAccept(ctx context.Context,
tx *wire.MsgTx) (bool, string, error) {
// TestMempoolAccept tests whether one or more transactions would be
// accepted by the mempool. LND's WalletController does not expose a
// testmempoolaccept equivalent, so every call returns "not supported"
// here — callers that treat preflight as best-effort should log and
// continue.
func (b *LNDBackend) TestMempoolAccept(_ context.Context,
_ ...*wire.MsgTx) ([]chainsource.MempoolAcceptResult, error) {

// LND's WalletController doesn't provide a test mempool accept
// interface. This would require direct RPC access to the underlying
// Bitcoin node.
return false, "", fmt.Errorf("test mempool accept not supported by " +
return nil, fmt.Errorf("test mempool accept not supported by " +
"LND backend")
}

Expand Down
46 changes: 41 additions & 5 deletions chainsource/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,40 @@ package chainsource

import (
"context"
"errors"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
)

// ErrPackageMempoolAcceptUnsupported is returned by ChainBackend
// implementations whose underlying RPC cannot test a multi-transaction
// package for mempool acceptance. It is distinct from a per-tx
// "rejected" outcome: the backend never evaluated the package at all.
// Callers that treat package preflight as best-effort should downgrade
// this error to a soft-miss; callers that require package validation
// should surface it as a hard failure.
var ErrPackageMempoolAcceptUnsupported = errors.New(
"package testmempoolaccept not supported by backend",
)

// MempoolAcceptResult is the per-transaction outcome of a
// TestMempoolAccept call. One result is returned for each input tx, in
// the same order.
type MempoolAcceptResult struct {
// Txid is the transaction hash the result applies to.
Txid chainhash.Hash

// Accepted reports whether the backend would accept the
// transaction into its mempool.
Accepted bool

// Reason carries the backend's human-readable rejection reason
// when Accepted is false. Empty on acceptance.
Reason string
}

// ChainBackend defines the interface that must be implemented by all
// blockchain backend providers. This abstraction allows the ChainSource actor
// to work with different backends (lnd's chainntnfs, block explorers like
Expand All @@ -28,12 +56,20 @@ type ChainBackend interface {
// according to the backend's view.
BestBlock(ctx context.Context) (int32, chainhash.Hash, error)

// TestMempoolAccept tests whether a transaction would be accepted by
// the mempool without actually broadcasting it. Returns true if the
// transaction would be accepted, false with a rejection reason if not.
// Not all backends may support this operation.
// TestMempoolAccept tests whether one or more transactions would be
// accepted by the mempool without actually broadcasting them. When
// len(txs) > 1 the backend must evaluate the transactions as a
// package (matching Bitcoin Core's testmempoolaccept JSON array
// form); backends that can only validate individual transactions
// must return ErrPackageMempoolAcceptUnsupported rather than
// silently evaluating the first tx in isolation.
//
// The returned slice has one entry per input tx, in the same
// order. Not all backends may support this operation at all; those
// should return a non-nil error from the single-tx call so callers
// can distinguish "rejected" from "not evaluated".
TestMempoolAccept(ctx context.Context,
tx *wire.MsgTx) (bool, string, error)
txs ...*wire.MsgTx) ([]MempoolAcceptResult, error)

// BroadcastTx broadcasts a transaction to the network. The label
// parameter is optional and may be used for wallet tracking. Returns
Expand Down
33 changes: 24 additions & 9 deletions chainsource/chainsource.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,20 +148,28 @@ func (a *ChainSourceActor) handleBestHeight(ctx context.Context,
}

// handleTestMempoolAccept processes a mempool acceptance test request by
// checking if the given transaction would be accepted by the mempool without
// actually broadcasting it.
// checking if one or more transactions would be accepted by the mempool
// without actually broadcasting them. Multi-transaction requests are
// forwarded to the backend as a package test; backends that do not
// support package evaluation return ErrPackageMempoolAcceptUnsupported
// and the error is surfaced to the caller.
func (a *ChainSourceActor) handleTestMempoolAccept(ctx context.Context,
req *TestMempoolAcceptRequest) fn.Result[ChainSourceResp] {

accepted, reason, err := a.cfg.Backend.TestMempoolAccept(ctx, req.Tx)
if len(req.Txs) == 0 {
return fn.Err[ChainSourceResp](fmt.Errorf(
"TestMempoolAcceptRequest.Txs must have at least " +
"one transaction"))
}

results, err := a.cfg.Backend.TestMempoolAccept(ctx, req.Txs...)
if err != nil {
return fn.Err[ChainSourceResp](fmt.Errorf("failed to test "+
"mempool accept: %w", err))
}

return fn.Ok[ChainSourceResp](&TestMempoolAcceptResponse{
Accepted: accepted,
Reason: reason,
Results: results,
})
}

Expand Down Expand Up @@ -194,12 +202,19 @@ func (a *ChainSourceActor) handleBroadcastTx(ctx context.Context,
// If supported by the backend, test mempool acceptance as a
// best-effort signal that the transaction is already known.
// This is useful for backends that return non-standard error
// strings
// from BroadcastTx but provide a structured reject reason via
// testmempoolaccept.
accepted, reason, acceptErr := a.cfg.Backend.TestMempoolAccept(
// strings from BroadcastTx but provide a structured reject
// reason via testmempoolaccept.
results, acceptErr := a.cfg.Backend.TestMempoolAccept(
ctx, req.Tx,
)
var (
accepted bool
reason string
)
if acceptErr == nil && len(results) > 0 {
accepted = results[0].Accepted
reason = results[0].Reason
}
switch {
case acceptErr == nil && accepted:
a.logger(ctx).DebugS(ctx,
Expand Down
8 changes: 4 additions & 4 deletions chainsource/chainsource_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ func (b *errorBackend) BestBlock(ctx context.Context) (int32,
return 0, chainhash.Hash{}, b.err
}

func (b *errorBackend) TestMempoolAccept(ctx context.Context,
tx *wire.MsgTx) (bool, string, error) {
func (b *errorBackend) TestMempoolAccept(_ context.Context,
_ ...*wire.MsgTx) ([]MempoolAcceptResult, error) {

return false, "", b.err
return nil, b.err
}

func (b *errorBackend) BroadcastTx(ctx context.Context, tx *wire.MsgTx,
Expand Down Expand Up @@ -190,7 +190,7 @@ func TestChainSourceActorBackendErrors(t *testing.T) {
tx := wire.NewMsgTx(2)
mempoolResult := ref.Ask(
ctx, &TestMempoolAcceptRequest{
Tx: tx,
Txs: []*wire.MsgTx{tx},
},
).Await(ctx)

Expand Down
42 changes: 33 additions & 9 deletions chainsource/chainsource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,18 @@ func (m *mockBackend) BestBlock(ctx context.Context) (int32, chainhash.Hash,
return m.bestHeight, m.bestHash, nil
}

func (m *mockBackend) TestMempoolAccept(ctx context.Context,
tx *wire.MsgTx) (bool, string, error) {
func (m *mockBackend) TestMempoolAccept(_ context.Context,
txs ...*wire.MsgTx) ([]MempoolAcceptResult, error) {

results := make([]MempoolAcceptResult, len(txs))
for i, tx := range txs {
results[i] = MempoolAcceptResult{
Txid: tx.TxHash(),
Accepted: true,
}
}

return true, "", nil
return results, nil
}

func (m *mockBackend) BroadcastTx(ctx context.Context, tx *wire.MsgTx,
Expand Down Expand Up @@ -92,10 +100,23 @@ func (b *broadcastErrorBackend) BroadcastTx(ctx context.Context, tx *wire.MsgTx,
}

// TestMempoolAccept returns the configured mempool acceptance values.
func (b *broadcastErrorBackend) TestMempoolAccept(ctx context.Context,
tx *wire.MsgTx) (bool, string, error) {
func (b *broadcastErrorBackend) TestMempoolAccept(_ context.Context,
txs ...*wire.MsgTx) ([]MempoolAcceptResult, error) {

if b.mempoolErr != nil {
return nil, b.mempoolErr
}

return b.mempoolAccepted, b.mempoolReason, b.mempoolErr
results := make([]MempoolAcceptResult, len(txs))
for i, tx := range txs {
results[i] = MempoolAcceptResult{
Txid: tx.TxHash(),
Accepted: b.mempoolAccepted,
Reason: b.mempoolReason,
}
}

return results, nil
}

func (m *mockBackend) RegisterConf(ctx context.Context,
Expand Down Expand Up @@ -191,7 +212,9 @@ func TestChainSourceActorTestMempoolAccept(t *testing.T) {

ctx := t.Context()
tx := wire.NewMsgTx(2)
future := ref.Ask(ctx, &TestMempoolAcceptRequest{Tx: tx})
future := ref.Ask(ctx, &TestMempoolAcceptRequest{
Txs: []*wire.MsgTx{tx},
})

result := future.Await(ctx)
require.True(t, result.IsOk())
Expand All @@ -200,8 +223,9 @@ func TestChainSourceActorTestMempoolAccept(t *testing.T) {
require.NoError(t, err)
acceptResp, ok := resp.(*TestMempoolAcceptResponse)
require.True(t, ok)
require.True(t, acceptResp.Accepted)
require.Empty(t, acceptResp.Reason)
require.Len(t, acceptResp.Results, 1)
require.True(t, acceptResp.Results[0].Accepted)
require.Empty(t, acceptResp.Results[0].Reason)
}

// TestChainSourceActorBroadcastTx tests transaction broadcasting through the
Expand Down
27 changes: 14 additions & 13 deletions chainsource/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,18 @@ func (m *BestHeightResponse) MessageType() string {
// chainSourceRespSealed implements the sealed ChainSourceResp interface.
func (m *BestHeightResponse) chainSourceRespSealed() {}

// TestMempoolAcceptRequest requests a test of whether a transaction would be
// accepted by the mempool without actually broadcasting it. This is useful for
// validating transactions before broadcast.
// TestMempoolAcceptRequest requests a test of whether one or more
// transactions would be accepted by the mempool without actually
// broadcasting them. Passing more than one transaction asks the backend
// to evaluate the set as a package (per Bitcoin Core's
// testmempoolaccept RPC).
type TestMempoolAcceptRequest struct {
actor.BaseMessage

// Tx is the transaction to test for mempool acceptance.
Tx *wire.MsgTx
// Txs are the transactions to test for mempool acceptance. One
// transaction performs a single-tx test; multiple transactions
// request a package test.
Txs []*wire.MsgTx
}

// MessageType returns the message type identifier for logging and debugging.
Expand All @@ -113,17 +117,14 @@ func (m *TestMempoolAcceptRequest) MessageType() string {
// chainSourceMsgSealed implements the sealed ChainSourceMsg interface.
func (m *TestMempoolAcceptRequest) chainSourceMsgSealed() {}

// TestMempoolAcceptResponse contains the result of a mempool acceptance test.
// TestMempoolAcceptResponse contains the per-transaction results of a
// mempool acceptance test.
type TestMempoolAcceptResponse struct {
actor.BaseMessage

// Accepted indicates whether the transaction would be accepted by the
// mempool.
Accepted bool

// Reason contains a human-readable explanation if the transaction was
// rejected, or is empty if accepted.
Reason string
// Results has one entry per tx in the original request, in the
// same order.
Results []MempoolAcceptResult
}

// MessageType returns the message type identifier for logging and debugging.
Expand Down
2 changes: 1 addition & 1 deletion chainsource/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestMessageTypes(t *testing.T) {
{
name: "TestMempoolAcceptRequest",
msg: &TestMempoolAcceptRequest{
Tx: wire.NewMsgTx(2),
Txs: []*wire.MsgTx{wire.NewMsgTx(2)},
},
expectedType: "TestMempoolAcceptRequest",
isChainSourceMsg: true,
Expand Down
1 change: 1 addition & 0 deletions darepod/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ var allSubsystems = []string{
lndbackend.Subsystem,
indexer.Subsystem,
db.Subsystem,
"TXCF",
}

// SetupLoggersWithShutdownFn registers all subsystem loggers using a plain
Expand Down
Loading
Loading