Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ linters:
- google.golang.org/protobuf
- github.com/lightningnetwork/lnd/sqldb
- github.com/lightningnetwork/lightning-onion
- github.com/btcsuite/btcwallet
- github.com/btcsuite/btcd
replace-local: true

gosec:
Expand Down
8 changes: 8 additions & 0 deletions chainreg/no_chain_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ func (n *NoChainSource) TestMempoolAccept([]*wire.MsgTx,
return nil, nil
}

// SubmitPackage is a stub implementation of the chain.Interface method for
// NoChainSource; it never submits anything as there is no chain backend.
func (n *NoChainSource) SubmitPackage([]*wire.MsgTx,
*float64) (*btcjson.SubmitPackageResult, error) {
Comment thread
ellemouton marked this conversation as resolved.

return nil, nil
}

func (n *NoChainSource) MapRPCErr(err error) error {
return err
}
Expand Down
72 changes: 72 additions & 0 deletions cmd/commands/walletrpc_active.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func walletCommands() []cli.Command {
listSweepsCommand,
labelTxCommand,
publishTxCommand,
submitPackageCommand,
getTxCommand,
removeTxCommand,
releaseOutputCommand,
Expand Down Expand Up @@ -715,6 +716,77 @@ func publishTransaction(ctx *cli.Context) error {
return nil
}

var submitPackageCommand = cli.Command{
Name: "submitpackage",
Usage: "Submit a package of related transactions for atomic " +
"validation and acceptance.",
ArgsUsage: "parent_tx_hex... child_tx_hex",
Description: `
Submit a package of related, topologically-sorted raw transactions
(unconfirmed parents first and the child last) to the chain backend
for atomic validation and acceptance via the submitpackage RPC.

This allows a zero-fee v3/TRUC parent to be accepted via its
fee-paying CPFP child, which a standalone broadcast would reject.
Each argument is a hex-encoded raw transaction.
`,
Flags: []cli.Flag{
cli.Uint64Flag{
Name: "sat_per_vbyte",
Usage: "(optional) the maximum fee rate in sat/vByte " +
"allowed for any transaction in the package; " +
"omit to use the node default, set 0 to " +
"disable the limit",
},
},
Action: actionDecorator(submitPackage),
}

func submitPackage(ctx *cli.Context) error {
ctxc := getContext()

// Display the command's help message if we do not have at least one
// transaction.
if ctx.NArg() == 0 {
return cli.ShowCommandHelp(ctx, "submitpackage")
}

walletClient, cleanUp := getWalletClient(ctx)
defer cleanUp()

rawTxs := make([][]byte, 0, ctx.NArg())
for _, arg := range ctx.Args() {
tx, err := hex.DecodeString(arg)
if err != nil {
return err
}

rawTxs = append(rawTxs, tx)
}

// Only set the max fee rate when explicitly provided; otherwise leave
// it unset so the node applies its default.
var satPerVByte *uint64
if ctx.IsSet("sat_per_vbyte") {
rate := ctx.Uint64("sat_per_vbyte")
satPerVByte = &rate
}

resp, err := walletClient.SubmitPackage(
ctxc, &walletrpc.SubmitPackageRequest{
RawTxs: rawTxs,
SatPerVbyte: satPerVByte,
},
)
if err != nil {
return err
}

printRespJSON(resp)

return nil
}

var getTxCommand = cli.Command{
Name: "gettx",
Usage: "Returns details of a transaction.",
Expand Down
11 changes: 11 additions & 0 deletions docs/release-notes/release-notes-0.22.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,24 @@
channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new
`outgoing_chan_ids` field in `RouteFeeRequest`.

* A new
[`walletrpc.SubmitPackage`](https://github.com/lightningnetwork/lnd/pull/10900)
RPC submits a package of related transactions (parents first, child last) to
the chain backend via bitcoind's `submitpackage`, allowing a zero-fee v3/TRUC
parent to be accepted together with a fee-paying CPFP child.

## lncli Additions

* The `estimateroutefee` command now supports [restricting fee estimates to
specific first-hop outgoing
channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new
`--outgoing_chan_id` flag.

* A new
[`wallet submitpackage`](https://github.com/lightningnetwork/lnd/pull/10900)
command submits a package of hex-encoded transactions via the new
`SubmitPackage` RPC.

# Improvements

## Functional Updates
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ require (
github.com/btcsuite/btcd/wire/v2 v2.0.0
github.com/btcsuite/btclog v1.0.0
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b
github.com/btcsuite/btcwallet v0.17.0
github.com/btcsuite/btcwallet v0.18.0
github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0
github.com/btcsuite/btcwallet/wallet/txrules v1.3.0
github.com/btcsuite/btcwallet/walletdb v1.6.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ github.com/btcsuite/btclog v1.0.0 h1:sEkpKJMmfGiyZjADwEIgB1NSwMyfdD1FB8v6+w1T0Ns
github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ=
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b h1:MQ+Q6sDy37V1wP1Yu79A5KqJutolqUGwA99UZWQDWZM=
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b/go.mod h1:XItGUfVOxotJL8kkuk2Hj3EVow5KCugXl3wWfQ6K0AE=
github.com/btcsuite/btcwallet v0.17.0 h1:uDHH/4BLWMz3nEGmfVEPepcidKWq4CQ+ZfWY/w71PKk=
github.com/btcsuite/btcwallet v0.17.0/go.mod h1:1ZMc1EEskov+AKKv4kCMZqN8BwVh9rpXwEyxbeWy2A4=
github.com/btcsuite/btcwallet v0.18.0 h1:VSRClNLT7NX0wmJEGALz3jOZRRjWPpUdp7VI1Akie1o=
github.com/btcsuite/btcwallet v0.18.0/go.mod h1:1ZMc1EEskov+AKKv4kCMZqN8BwVh9rpXwEyxbeWy2A4=
github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0 h1:oIkGj32YK1CvWaJGlVwZA1f+y/KVHkfrd2PoST0ZpQs=
github.com/btcsuite/btcwallet/wallet/txauthor v1.4.0/go.mod h1:sGrBjcqQ8UPexuRajFs72+o544CJn3Pavv/5H0VAWVk=
github.com/btcsuite/btcwallet/wallet/txrules v1.3.0 h1:D5aGMwWIxdqek3xEJs4eOdMoh6iga2EI2xSlaXCdnNo=
Expand Down
4 changes: 4 additions & 0 deletions itest/list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@ var allTestCases = []*lntest.TestCase{
Name: "sign output raw",
TestFunc: testSignOutputRaw,
},
{
Name: "submit package",
TestFunc: testSubmitPackage,
},
{
Name: "sign verify message",
TestFunc: testSignVerifyMessage,
Expand Down
206 changes: 206 additions & 0 deletions itest/lnd_submit_package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package itest

import (
"bytes"

btcaddr "github.com/btcsuite/btcd/address/v2"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/txscript/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/signrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/stretchr/testify/require"
)

// testSubmitPackage tests that the WalletKit.SubmitPackage RPC relays a v3
// (TRUC) transaction package: a zero-fee parent that would be rejected by a
// standalone broadcast (below the minimum relay fee) is accepted together with
// a fee-paying CPFP child whose combined package feerate clears policy.
//
// This requires a bitcoind chain backend, as btcd has no submitpackage RPC and
// cannot relay zero-fee v3 transactions; run with backend=bitcoind. The
// zero-fee parent can only enter the mempool via package evaluation (a
// standalone submission is rejected for the min relay fee), so a successful
// SubmitPackage proves the CPFP package path worked end to end.
func testSubmitPackage(ht *lntest.HarnessTest) {
// submitpackage is a bitcoind RPC: btcd has no equivalent and neutrino
// has no mempool, so this test only applies to the bitcoind backend.
if ht.ChainBackendName() != "bitcoind" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could we also run it with neutrino? as the implementation would suggest it works there

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.

good point — it does work on neutrino, but only against a package-aware miner. neutrino has no mempool, so SubmitPackage just broadcasts parent+child and leans on the peer's 1p1c relay — that needs the bitcoind miner (minerbackend=bitcoind, v28+); against the default btcd miner the zero-fee parent is simply rejected and never assembles. so a neutrino row would have to be gated on minerbackend=bitcoind and assert confirmation (the neutrino result itself is a synthetic best-effort one). afaict there's no backend=neutrino+minerbackend=bitcoind combo in CI today, so it'd be skipped everywhere unless we add that job. happy to add the gated test (+ the CI combo) if you think it's worth it — wdyt?

ht.Skipf("submitpackage requires the bitcoind backend, got %v",
ht.ChainBackendName())
}

// The zero-fee v3 parent only propagates to (and is observable in) the
// mempool of a package-relay-capable node, so the miner must also be
// bitcoind. With the default btcd miner the package is submitted to
// Alice's bitcoind successfully but never relays to the miner, so the
// mempool assertions below would time out.
if ht.Miner().BackendName() != "bitcoind" {
ht.Skipf("submitpackage requires a bitcoind miner for the "+
"zero-fee v3 package to relay, got %v miner",
ht.Miner().BackendName())
}

alice := ht.NewNodeWithCoins("Alice", nil)

const (
fundAmt = int64(btcutil.SatoshiPerBitcoin)

// childFee is paid by the child for the whole package. It
// must cover both transactions' weight at >= the min relay
// fee; a few thousand sats is comfortably above that.
childFee = int64(20_000)

// p2wkhKeyFamily is a custom key family so the derived keys
// (and thus the addresses we control via SignOutputRaw) are
// independent of the node's normal key usage.
p2wkhKeyFamily = 44
)

// p2wkhKey derives a fresh key and returns it together with the p2wkh
// address/pkScript it controls, which we can later spend via the
// SignOutputRaw RPC.
p2wkhKey := func() (*signrpc.KeyDescriptor, *btcec.PublicKey,
btcaddr.Address, []byte) {

keyDesc := alice.RPC.DeriveNextKey(&walletrpc.KeyReq{
KeyFamily: p2wkhKeyFamily,
})

pubKey, err := btcec.ParsePubKey(keyDesc.RawKeyBytes)
require.NoError(ht, err)

addr, err := btcaddr.NewAddressWitnessPubKeyHash(
btcaddr.Hash160(pubKey.SerializeCompressed()),
harnessNetParams,
)
require.NoError(ht, err)

pkScript, err := txscript.PayToAddrScript(addr)
require.NoError(ht, err)

return keyDesc, pubKey, addr, pkScript
}

// signP2WKHInput signs input idx of tx (spending a p2wkh output
// with the given pkScript and value) via SignOutputRaw and attaches
// the witness.
signP2WKHInput := func(tx *wire.MsgTx, idx int, pkScript []byte,
value int64, keyDesc *signrpc.KeyDescriptor,
pubKey *btcec.PublicKey) {

var buf bytes.Buffer
require.NoError(ht, tx.Serialize(&buf))

signResp := alice.RPC.SignOutputRaw(&signrpc.SignReq{
RawTxBytes: buf.Bytes(),
SignDescs: []*signrpc.SignDescriptor{{
Output: &signrpc.TxOut{
PkScript: pkScript,
Value: value,
},
InputIndex: int32(idx),
KeyDesc: keyDesc,
Sighash: uint32(txscript.SigHashAll),
WitnessScript: pkScript,
}},
})

tx.TxIn[idx].Witness = wire.TxWitness{
append(signResp.RawSigs[0], byte(txscript.SigHashAll)),
pubKey.SerializeCompressed(),
}
}

serialize := func(tx *wire.MsgTx) []byte {
var buf bytes.Buffer
require.NoError(ht, tx.Serialize(&buf))

return buf.Bytes()
}

// Fund a p2wkh output we control: send coins to a key-derived
// address and confirm it, so the parent has a confirmed input to spend.
parentInKey, parentInPub, parentInAddr, parentInScript := p2wkhKey()
alice.RPC.SendCoins(&lnrpc.SendCoinsRequest{
Addr: parentInAddr.String(),
Amount: fundAmt,
TargetConf: 6,
})
fundTxid := ht.AssertNumTxsInMempool(1)[0]
fundOutIdx := ht.GetOutputIndex(fundTxid, parentInAddr.String())
ht.MineBlocksAndAssertNumTxes(1, 1)

// The child will spend the parent's output, so derive a key we control
// for it and use its script as the parent's output.
childInKey, childInPub, _, childInScript := p2wkhKey()

// Build the zero-fee v3 parent: spend the confirmed input and pay the
// full value to the child-input script, leaving no fee.
parent := wire.NewMsgTx(3)
parent.AddTxIn(&wire.TxIn{
PreviousOutPoint: wire.OutPoint{
Hash: fundTxid,
Index: uint32(fundOutIdx),
},
})
parent.AddTxOut(wire.NewTxOut(fundAmt, childInScript))
signP2WKHInput(
parent, 0, parentInScript, fundAmt, parentInKey, parentInPub,
)

// Build the v3 CPFP child: spend the parent's unconfirmed output
// and pay childFee, which covers the whole package.
childOut := alice.RPC.NewAddress(&lnrpc.NewAddressRequest{
Type: AddrTypeWitnessPubkeyHash,
})
childOutAddr, err := btcaddr.DecodeAddress(
childOut.Address, harnessNetParams,
)
require.NoError(ht, err)
childOutScript, err := txscript.PayToAddrScript(childOutAddr)
require.NoError(ht, err)

child := wire.NewMsgTx(3)
child.AddTxIn(&wire.TxIn{
PreviousOutPoint: wire.OutPoint{
Hash: parent.TxHash(),
Index: 0,
},
})
child.AddTxOut(wire.NewTxOut(fundAmt-childFee, childOutScript))
signP2WKHInput(child, 0, childInScript, fundAmt, childInKey, childInPub)

// Submit the two transactions as a package. A max fee rate of 0
// disables the fee-rate ceiling so a high-feerate CPFP child is
// never rejected.
noFeeLimit := uint64(0)
resp := alice.RPC.SubmitPackage(&walletrpc.SubmitPackageRequest{
RawTxs: [][]byte{serialize(parent), serialize(child)},
SatPerVbyte: &noFeeLimit,
})

// The whole package must be accepted, with a per-tx result (keyed by
// wtxid) for each transaction and no per-tx error.
require.Equal(ht, "success", resp.PackageMsg)
require.Len(ht, resp.TxResults, 2)
for _, txResult := range resp.TxResults {
require.Emptyf(
ht, txResult.Error, "tx %s rejected", txResult.Txid,
)
}
Comment thread
ellemouton marked this conversation as resolved.

// The accepted package must now be in the mempool: both the zero-fee
// parent and its fee-paying CPFP child. This proves the package
// actually relayed, not merely that the RPC returned success.
ht.AssertTxInMempool(parent.TxHash())
ht.AssertTxInMempool(child.TxHash())

// Mine the package so it confirms (the strongest end-to-end proof the
// CPFP package relayed) and the mempool is clean for the harness's
// end-of-test teardown check.
ht.MineBlocksAndAssertNumTxes(1, 2)
}
13 changes: 13 additions & 0 deletions lnmock/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ func (m *MockChain) TestMempoolAccept(txns []*wire.MsgTx, maxFeeRate float64) (
return args.Get(0).([]*btcjson.TestMempoolAcceptResult), args.Error(1)
}

// SubmitPackage is a mock implementation of the chain.Interface method.
func (m *MockChain) SubmitPackage(txns []*wire.MsgTx,
maxFeeRate *float64) (*btcjson.SubmitPackageResult, error) {

args := m.Called(txns, maxFeeRate)

if args.Get(0) == nil {
return nil, args.Error(1)
}

return args.Get(0).(*btcjson.SubmitPackageResult), args.Error(1)
}

func (m *MockChain) MapRPCErr(err error) error {
args := m.Called(err)

Expand Down
Loading
Loading