lnwallet+walletrpc: add SubmitPackage for v3 CPFP package relay

Add SubmitPackage to the lnwallet.WalletController interface and a new
WalletKit.SubmitPackage RPC, so a client of lnd can relay a package of
related transactions (parents first, child last) through lnd's own chain
connection. This lets a zero-fee v3/TRUC parent be accepted via its
fee-paying CPFP child without the caller needing a separate connection to
the chain backend.

BtcWallet.SubmitPackage forwards to the chain backend's submitpackage for
bitcoind/btcd, and broadcasts each transaction individually for neutrino
(no mempool; relies on the peer's 1p1c package relay). The WalletKit
handler maps the proto request/response to the btcjson result and is
gated by the onchain:write macaroon permission. Mock controllers and the
no-chain backend gain trivial implementations.
This commit is contained in:
Elle Mouton 2026-06-11 13:35:22 -07:00
parent 0dbe2b1029
commit f55c0565d0
No known key found for this signature in database
GPG key ID: D7D916376026F177
17 changed files with 1019 additions and 215 deletions

View file

@ -221,6 +221,15 @@ func (n *NoChainSource) TestMempoolAccept([]*wire.MsgTx,
return nil, nil
}
// SubmitPackage is a stub implementation of the chain.Interface method for
// NoChainSource; there is no chain backend, so it always returns
// errNotImplemented.
func (n *NoChainSource) SubmitPackage([]*wire.MsgTx,
*float64) (*btcjson.SubmitPackageResult, error) {
return nil, errNotImplemented
}
func (n *NoChainSource) MapRPCErr(err error) error {
return err
}

2
go.mod
View file

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

4
go.sum
View file

@ -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=

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -602,6 +602,40 @@ func local_request_WalletKit_PublishTransaction_0(ctx context.Context, marshaler
}
func request_WalletKit_SubmitPackage_0(ctx context.Context, marshaler runtime.Marshaler, client WalletKitClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var protoReq SubmitPackageRequest
var metadata runtime.ServerMetadata
newReader, berr := utilities.IOReaderFactory(req.Body)
if berr != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr)
}
if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.SubmitPackage(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_WalletKit_SubmitPackage_0(ctx context.Context, marshaler runtime.Marshaler, server WalletKitServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var protoReq SubmitPackageRequest
var metadata runtime.ServerMetadata
newReader, berr := utilities.IOReaderFactory(req.Body)
if berr != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr)
}
if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.SubmitPackage(ctx, &protoReq)
return msg, metadata, err
}
func request_WalletKit_RemoveTransaction_0(ctx context.Context, marshaler runtime.Marshaler, client WalletKitClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var protoReq GetTransactionRequest
var metadata runtime.ServerMetadata
@ -1411,6 +1445,31 @@ func RegisterWalletKitHandlerServer(ctx context.Context, mux *runtime.ServeMux,
})
mux.Handle("POST", pattern_WalletKit_SubmitPackage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
var err error
var annotatedContext context.Context
annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/walletrpc.WalletKit/SubmitPackage", runtime.WithHTTPPathPattern("/v2/wallet/tx/package"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_WalletKit_SubmitPackage_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_WalletKit_SubmitPackage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle("POST", pattern_WalletKit_RemoveTransaction_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -2101,6 +2160,28 @@ func RegisterWalletKitHandlerClient(ctx context.Context, mux *runtime.ServeMux,
})
mux.Handle("POST", pattern_WalletKit_SubmitPackage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
var err error
var annotatedContext context.Context
annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/walletrpc.WalletKit/SubmitPackage", runtime.WithHTTPPathPattern("/v2/wallet/tx/package"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_WalletKit_SubmitPackage_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_WalletKit_SubmitPackage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle("POST", pattern_WalletKit_RemoveTransaction_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -2381,6 +2462,8 @@ var (
pattern_WalletKit_PublishTransaction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "wallet", "tx"}, ""))
pattern_WalletKit_SubmitPackage_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v2", "wallet", "tx", "package"}, ""))
pattern_WalletKit_RemoveTransaction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "wallet", "removetx"}, ""))
pattern_WalletKit_SendOutputs_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "wallet", "send"}, ""))
@ -2439,6 +2522,8 @@ var (
forward_WalletKit_PublishTransaction_0 = runtime.ForwardResponseMessage
forward_WalletKit_SubmitPackage_0 = runtime.ForwardResponseMessage
forward_WalletKit_RemoveTransaction_0 = runtime.ForwardResponseMessage
forward_WalletKit_SendOutputs_0 = runtime.ForwardResponseMessage

View file

@ -447,6 +447,31 @@ func RegisterWalletKitJSONCallbacks(registry map[string]func(ctx context.Context
callback(string(respBytes), nil)
}
registry["walletrpc.WalletKit.SubmitPackage"] = func(ctx context.Context,
conn *grpc.ClientConn, reqJSON string, callback func(string, error)) {
req := &SubmitPackageRequest{}
err := marshaler.Unmarshal([]byte(reqJSON), req)
if err != nil {
callback("", err)
return
}
client := NewWalletKitClient(conn)
resp, err := client.SubmitPackage(ctx, req)
if err != nil {
callback("", err)
return
}
respBytes, err := marshaler.Marshal(resp)
if err != nil {
callback("", err)
return
}
callback(string(respBytes), nil)
}
registry["walletrpc.WalletKit.RemoveTransaction"] = func(ctx context.Context,
conn *grpc.ClientConn, reqJSON string, callback func(string, error)) {

View file

@ -208,6 +208,20 @@ service WalletKit {
*/
rpc PublishTransaction (Transaction) returns (PublishResponse);
/* lncli: `wallet submitpackage`
SubmitPackage submits a package of related transactions (topologically
sorted, unconfirmed parents first and the child last) for atomic
validation and acceptance. Real package submission is only performed by
the bitcoind backend, via the node's submitpackage RPC, which lets a
zero-fee v3/TRUC parent be accepted via its fee-paying CPFP child. The
btcd backend does not support submitpackage and returns an error. A
neutrino light client has no mempool and cannot atomically accept a
package; as a best effort it broadcasts the transactions individually and
relies on a peer's 1p1c package relay, returning an unverified result
(not a package-accept verdict).
*/
rpc SubmitPackage (SubmitPackageRequest) returns (SubmitPackageResponse);
/* lncli: `wallet removetx`
RemoveTransaction attempts to remove the provided transaction from the
internal transaction store of the wallet.
@ -801,6 +815,48 @@ message PublishResponse {
string publish_error = 1;
}
message SubmitPackageRequest {
/*
The raw serialized transactions forming the package, topologically sorted
with unconfirmed parents first and the child last.
*/
repeated bytes raw_txs = 1;
/*
Optional per-transaction fee-rate ceiling in sat/vByte (mapped onto the
submitpackage maxfeerate). When unset the node's default is used; an
explicit 0 means no limit, which is required for a CPFP child whose
standalone feerate is high.
*/
optional uint64 sat_per_vbyte = 2;
}
message SubmitPackageTxResult {
// The transaction id (txid) in hex.
string txid = 1;
// If non-empty, the reason this transaction was rejected.
string error = 2;
/*
If non-empty, the wtxid (in hex) of a transaction with the same txid but a
different witness that was already in the mempool; the submitted
transaction was ignored as a duplicate (witness replacement).
*/
string other_wtxid = 3;
}
message SubmitPackageResponse {
// A summary message; "success" when the whole package was accepted.
string package_msg = 1;
// Per-transaction results keyed by wtxid (hex).
map<string, SubmitPackageTxResult> tx_results = 2;
// The txids of transactions evicted via package RBF.
repeated string replaced_transactions = 3;
}
message RemoveTransactionResponse {
// The status of the remove transaction operation.
string status = 1;

View file

@ -832,6 +832,39 @@
]
}
},
"/v2/wallet/tx/package": {
"post": {
"summary": "lncli: `wallet submitpackage`\nSubmitPackage submits a package of related transactions (topologically\nsorted, unconfirmed parents first and the child last) for atomic\nvalidation and acceptance. Real package submission is only performed by\nthe bitcoind backend, via the node's submitpackage RPC, which lets a\nzero-fee v3/TRUC parent be accepted via its fee-paying CPFP child. The\nbtcd backend does not support submitpackage and returns an error. A\nneutrino light client has no mempool and cannot atomically accept a\npackage; as a best effort it broadcasts the transactions individually and\nrelies on a peer's 1p1c package relay, returning an unverified result\n(not a package-accept verdict).",
"operationId": "WalletKit_SubmitPackage",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/walletrpcSubmitPackageResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/walletrpcSubmitPackageRequest"
}
}
],
"tags": [
"WalletKit"
]
}
},
"/v2/wallet/utxos": {
"post": {
"summary": "ListUnspent returns a list of all utxos spendable by the wallet with a\nnumber of confirmations between the specified minimum and maximum. By\ndefault, all utxos are listed. To list only the unconfirmed utxos, set\nthe unconfirmed_only to true.",
@ -2180,6 +2213,64 @@
}
}
},
"walletrpcSubmitPackageRequest": {
"type": "object",
"properties": {
"raw_txs": {
"type": "array",
"items": {
"type": "string",
"format": "byte"
},
"description": "The raw serialized transactions forming the package, topologically sorted\nwith unconfirmed parents first and the child last."
},
"sat_per_vbyte": {
"type": "string",
"format": "uint64",
"description": "Optional per-transaction fee-rate ceiling in sat/vByte (mapped onto the\nsubmitpackage maxfeerate). When unset the node's default is used; an\nexplicit 0 means no limit, which is required for a CPFP child whose\nstandalone feerate is high."
}
}
},
"walletrpcSubmitPackageResponse": {
"type": "object",
"properties": {
"package_msg": {
"type": "string",
"description": "A summary message; \"success\" when the whole package was accepted."
},
"tx_results": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/walletrpcSubmitPackageTxResult"
},
"description": "Per-transaction results keyed by wtxid (hex)."
},
"replaced_transactions": {
"type": "array",
"items": {
"type": "string"
},
"description": "The txids of transactions evicted via package RBF."
}
}
},
"walletrpcSubmitPackageTxResult": {
"type": "object",
"properties": {
"txid": {
"type": "string",
"description": "The transaction id (txid) in hex."
},
"error": {
"type": "string",
"description": "If non-empty, the reason this transaction was rejected."
},
"other_wtxid": {
"type": "string",
"description": "If non-empty, the wtxid (in hex) of a transaction with the same txid but a\ndifferent witness that was already in the mempool; the submitted\ntransaction was ignored as a duplicate (witness replacement)."
}
}
},
"walletrpcTapLeaf": {
"type": "object",
"properties": {

View file

@ -34,6 +34,9 @@ http:
- selector: walletrpc.WalletKit.PublishTransaction
post: "/v2/wallet/tx"
body: "*"
- selector: walletrpc.WalletKit.SubmitPackage
post: "/v2/wallet/tx/package"
body: "*"
- selector: walletrpc.WalletKit.SendOutputs
post: "/v2/wallet/send"
body: "*"

View file

@ -156,6 +156,18 @@ type WalletKitClient interface {
// attempt to re-broadcast the transaction on start up, until it enters the
// chain.
PublishTransaction(ctx context.Context, in *Transaction, opts ...grpc.CallOption) (*PublishResponse, error)
// lncli: `wallet submitpackage`
// SubmitPackage submits a package of related transactions (topologically
// sorted, unconfirmed parents first and the child last) for atomic
// validation and acceptance. Real package submission is only performed by
// the bitcoind backend, via the node's submitpackage RPC, which lets a
// zero-fee v3/TRUC parent be accepted via its fee-paying CPFP child. The
// btcd backend does not support submitpackage and returns an error. A
// neutrino light client has no mempool and cannot atomically accept a
// package; as a best effort it broadcasts the transactions individually and
// relies on a peer's 1p1c package relay, returning an unverified result
// (not a package-accept verdict).
SubmitPackage(ctx context.Context, in *SubmitPackageRequest, opts ...grpc.CallOption) (*SubmitPackageResponse, error)
// lncli: `wallet removetx`
// RemoveTransaction attempts to remove the provided transaction from the
// internal transaction store of the wallet.
@ -443,6 +455,15 @@ func (c *walletKitClient) PublishTransaction(ctx context.Context, in *Transactio
return out, nil
}
func (c *walletKitClient) SubmitPackage(ctx context.Context, in *SubmitPackageRequest, opts ...grpc.CallOption) (*SubmitPackageResponse, error) {
out := new(SubmitPackageResponse)
err := c.cc.Invoke(ctx, "/walletrpc.WalletKit/SubmitPackage", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletKitClient) RemoveTransaction(ctx context.Context, in *GetTransactionRequest, opts ...grpc.CallOption) (*RemoveTransactionResponse, error) {
out := new(RemoveTransactionResponse)
err := c.cc.Invoke(ctx, "/walletrpc.WalletKit/RemoveTransaction", in, out, opts...)
@ -682,6 +703,18 @@ type WalletKitServer interface {
// attempt to re-broadcast the transaction on start up, until it enters the
// chain.
PublishTransaction(context.Context, *Transaction) (*PublishResponse, error)
// lncli: `wallet submitpackage`
// SubmitPackage submits a package of related transactions (topologically
// sorted, unconfirmed parents first and the child last) for atomic
// validation and acceptance. Real package submission is only performed by
// the bitcoind backend, via the node's submitpackage RPC, which lets a
// zero-fee v3/TRUC parent be accepted via its fee-paying CPFP child. The
// btcd backend does not support submitpackage and returns an error. A
// neutrino light client has no mempool and cannot atomically accept a
// package; as a best effort it broadcasts the transactions individually and
// relies on a peer's 1p1c package relay, returning an unverified result
// (not a package-accept verdict).
SubmitPackage(context.Context, *SubmitPackageRequest) (*SubmitPackageResponse, error)
// lncli: `wallet removetx`
// RemoveTransaction attempts to remove the provided transaction from the
// internal transaction store of the wallet.
@ -864,6 +897,9 @@ func (UnimplementedWalletKitServer) ImportTapscript(context.Context, *ImportTaps
func (UnimplementedWalletKitServer) PublishTransaction(context.Context, *Transaction) (*PublishResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PublishTransaction not implemented")
}
func (UnimplementedWalletKitServer) SubmitPackage(context.Context, *SubmitPackageRequest) (*SubmitPackageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SubmitPackage not implemented")
}
func (UnimplementedWalletKitServer) RemoveTransaction(context.Context, *GetTransactionRequest) (*RemoveTransactionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveTransaction not implemented")
}
@ -1216,6 +1252,24 @@ func _WalletKit_PublishTransaction_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _WalletKit_SubmitPackage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitPackageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletKitServer).SubmitPackage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/walletrpc.WalletKit/SubmitPackage",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletKitServer).SubmitPackage(ctx, req.(*SubmitPackageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletKit_RemoveTransaction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetTransactionRequest)
if err := dec(in); err != nil {
@ -1489,6 +1543,10 @@ var WalletKit_ServiceDesc = grpc.ServiceDesc{
MethodName: "PublishTransaction",
Handler: _WalletKit_PublishTransaction_Handler,
},
{
MethodName: "SubmitPackage",
Handler: _WalletKit_SubmitPackage_Handler,
},
{
MethodName: "RemoveTransaction",
Handler: _WalletKit_RemoveTransaction_Handler,

View file

@ -94,6 +94,10 @@ var (
Entity: "onchain",
Action: "write",
}},
"/walletrpc.WalletKit/SubmitPackage": {{
Entity: "onchain",
Action: "write",
}},
"/walletrpc.WalletKit/SendOutputs": {{
Entity: "onchain",
Action: "write",
@ -696,6 +700,87 @@ func (w *WalletKit) PublishTransaction(ctx context.Context,
return &PublishResponse{}, nil
}
// maxPackageTxns is the maximum number of transactions accepted in a single
// SubmitPackage request. It mirrors bitcoind's MAX_PACKAGE_COUNT (the limit
// its submitpackage RPC enforces), so any package the backend could accept
// fits, while bounding the work an authenticated caller can force from
// deserializing an arbitrarily long raw_txs list.
const maxPackageTxns = 25
// SubmitPackage submits a package of related transactions (topologically
// sorted, unconfirmed parents first and the child last) to the wallet's chain
// backend for atomic validation and acceptance. This lets a zero-fee v3/TRUC
// parent confirm via its fee-paying CPFP child without the caller needing a
// separate connection to the chain backend.
func (w *WalletKit) SubmitPackage(_ context.Context,
req *SubmitPackageRequest) (*SubmitPackageResponse, error) {
if len(req.RawTxs) == 0 {
return nil, fmt.Errorf("must provide at least one transaction")
}
if len(req.RawTxs) > maxPackageTxns {
return nil, fmt.Errorf("package of %d transactions exceeds "+
"the maximum of %d", len(req.RawTxs), maxPackageTxns)
}
txns := make([]*wire.MsgTx, 0, len(req.RawTxs))
for _, raw := range req.RawTxs {
tx := &wire.MsgTx{}
if err := tx.Deserialize(bytes.NewReader(raw)); err != nil {
return nil, fmt.Errorf("unable to decode tx: %w", err)
}
txns = append(txns, tx)
}
// Map the optional sat/vByte ceiling onto the backend. An unset value
// uses the node's default; an explicit value (including 0, meaning no
// limit) is passed through unchanged.
var maxFeeRate *chainfee.SatPerVByte
if req.SatPerVbyte != nil {
rate := chainfee.SatPerVByte(*req.SatPerVbyte)
maxFeeRate = &rate
}
result, err := w.cfg.Wallet.SubmitPackage(txns, maxFeeRate)
if err != nil {
return nil, err
}
// Some backends (e.g. the no-chain source or mocks) may return a nil
// result; guard against a nil dereference below.
if result == nil {
return nil, fmt.Errorf("nil result from wallet backend")
}
numResults := len(result.TxResults)
resp := &SubmitPackageResponse{
PackageMsg: result.PackageMsg,
TxResults: make(map[string]*SubmitPackageTxResult, numResults),
ReplacedTransactions: make(
[]string, 0, len(result.ReplacedTransactions),
),
}
for _, replaced := range result.ReplacedTransactions {
resp.ReplacedTransactions = append(
resp.ReplacedTransactions, replaced.String(),
)
}
for wtxid, txResult := range result.TxResults {
entry := &SubmitPackageTxResult{Txid: txResult.TxID.String()}
if txResult.Error != nil {
entry.Error = *txResult.Error
}
if txResult.OtherWtxid != nil {
entry.OtherWtxid = txResult.OtherWtxid.String()
}
resp.TxResults[wtxid] = entry
}
return resp, nil
}
// RemoveTransaction attempts to remove the transaction and all of its
// descendants resulting from further spends of the outputs of the provided
// transaction id.

View file

@ -7,6 +7,7 @@ import (
"github.com/btcsuite/btcd/address/v2"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/btcutil/v2/hdkeychain"
"github.com/btcsuite/btcd/chaincfg/v2"
@ -241,6 +242,18 @@ func (w *WalletController) PublishTransaction(tx *wire.MsgTx, _ string) error {
return nil
}
// SubmitPackage publishes each transaction in the package individually,
// mirroring PublishTransaction.
func (w *WalletController) SubmitPackage(txns []*wire.MsgTx,
_ *chainfee.SatPerVByte) (*btcjson.SubmitPackageResult, error) {
for _, tx := range txns {
w.PublishedTransactions <- tx
}
return &btcjson.SubmitPackageResult{}, nil
}
// GetTransactionDetails currently does nothing.
func (w *WalletController) GetTransactionDetails(
txHash *chainhash.Hash) (*lnwallet.TransactionDetail, error) {

View file

@ -12,6 +12,7 @@ import (
"github.com/btcsuite/btcd/address/v2"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/btcutil/v2/hdkeychain"
"github.com/btcsuite/btcd/chaincfg/v2"
@ -1231,6 +1232,91 @@ func (b *BtcWallet) PublishTransaction(tx *wire.MsgTx, label string) error {
return mapRpcclientError(err)
}
// neutrinoBroadcastMsg is the PackageMsg returned by the neutrino best-effort
// path. It is deliberately not "success": a neutrino light client has no
// mempool, so it cannot confirm the package was accepted. It broadcasts the
// transactions and reports them as broadcast-but-unverified, which callers
// must treat as an unverified relay attempt, not a package-accept verdict.
const neutrinoBroadcastMsg = "broadcast-unverified"
// SubmitPackage submits a package of related transactions (topologically
// sorted, parents first and child last) for atomic validation and acceptance.
//
// Only the bitcoind backend performs real package submission, via the node's
// submitpackage RPC, which lets a zero-fee v3/TRUC parent be accepted via its
// fee-paying CPFP child (which sendrawtransaction rejects on its own). The
// btcd backend has no submitpackage handler and returns ErrUnimplemented.
//
// A neutrino light client has no mempool and cannot validate or atomically
// accept a package. As a best effort it broadcasts each transaction
// individually over the P2P network and relies on a peer's 1p1c package relay
// to assemble them. The returned PackageMsg is deliberately not "success": a
// light client cannot confirm acceptance, so callers must treat the result as
// an unverified broadcast rather than a package-accept verdict.
func (b *BtcWallet) SubmitPackage(txns []*wire.MsgTx,
maxFeeRate *chainfee.SatPerVByte) (*btcjson.SubmitPackageResult,
error) {
if b.chain.BackEnd() == "neutrino" {
// The best-effort neutrino broadcast goes through plain
// SendRawTransaction, which cannot enforce a fee-rate ceiling,
// so reject a caller-provided limit rather than silently
// ignoring it and giving a false sense of protection.
if maxFeeRate != nil {
return nil, fmt.Errorf("max fee rate is not " +
"supported for neutrino package broadcast")
}
for i, tx := range txns {
if err := b.PublishTransaction(tx, ""); err != nil {
return nil, fmt.Errorf("unable to "+
"broadcast package tx %d (%v): %w",
i, tx.TxHash(), err)
}
}
results := make(
map[string]btcjson.SubmitPackageTxResult, len(txns),
)
for _, tx := range txns {
results[tx.WitnessHash().String()] =
btcjson.SubmitPackageTxResult{TxID: tx.TxHash()}
}
return &btcjson.SubmitPackageResult{
PackageMsg: neutrinoBroadcastMsg,
TxResults: results,
}, nil
}
// bitcoind's submitpackage maxfeerate is expressed in BTC/kvB, so map
// the optional sat/vByte ceiling onto it. A nil ceiling leaves the node
// default unchanged; an explicit 0 disables the limit.
var maxFeeRateBTCPerKvB *float64
if maxFeeRate != nil {
btcPerKvB := satPerVByteToBTCPerKvB(*maxFeeRate)
maxFeeRateBTCPerKvB = &btcPerKvB
}
return b.chain.SubmitPackage(txns, maxFeeRateBTCPerKvB)
}
// vBytesPerKvB is the number of virtual bytes in a kilo-virtual-byte, used to
// convert a sat/vByte fee rate into the per-kvB unit bitcoind expects.
const vBytesPerKvB = 1000
// satPerVByteToBTCPerKvB converts a sat/vByte fee rate into the BTC/kvB unit
// expected by bitcoind's submitpackage maxfeerate argument: 1 sat/vByte is
// 1000 sat/kvB, and SatoshiPerBitcoin sats make a BTC, so
// BTC/kvB = sat/vByte * 1000 / SatoshiPerBitcoin.
//
// NOTE: the sat/vByte input is integer, so only whole-sat/vByte ceilings are
// expressible, and very large values lose precision once the float64 product
// exceeds 2^53.
func satPerVByteToBTCPerKvB(rate chainfee.SatPerVByte) float64 {
return float64(rate) * vBytesPerKvB / btcutil.SatoshiPerBitcoin
}
// LabelTransaction adds a label to a transaction. If the tx already
// has a label, this call will fail unless the overwrite parameter
// is set. Labels must not be empty, and they are limited to 500 chars.

View file

@ -0,0 +1,38 @@
package btcwallet
import (
"testing"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
)
// TestSatPerVByteToBTCPerKvB checks the sat/vByte -> BTC/kvB conversion used to
// map an lnd fee-rate ceiling onto bitcoind's submitpackage maxfeerate
// argument. A regression here would silently relax or tighten the user's fee
// ceiling, so the known reference points are pinned.
func TestSatPerVByteToBTCPerKvB(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rate chainfee.SatPerVByte
want float64
}{
{name: "zero", rate: 0, want: 0},
{name: "1 sat/vByte", rate: 1, want: 0.00001},
{name: "10 sat/vByte", rate: 10, want: 0.0001},
{name: "250 sat/vByte", rate: 250, want: 0.0025},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.InDelta(
t, tc.want, satPerVByteToBTCPerKvB(tc.rate),
1e-12,
)
})
}
}

View file

@ -9,6 +9,7 @@ import (
"github.com/btcsuite/btcd/address/v2"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/btcutil/v2/hdkeychain"
"github.com/btcsuite/btcd/chaincfg/v2"
@ -441,6 +442,19 @@ type WalletController interface {
// published transaction.
PublishTransaction(tx *wire.MsgTx, label string) error
// SubmitPackage submits a package of related transactions
// (topologically sorted, unconfirmed parents first and the child
// last) to the chain backend for atomic validation and acceptance.
// This lets a zero-fee v3/TRUC parent be accepted via its fee-paying
// CPFP child, which a standalone broadcast rejects. maxFeeRate is an
// optional per-transaction fee-rate ceiling in sat/vByte (nil leaves
// the node default unchanged). Backends without a mempool (e.g.
// neutrino) broadcast each transaction individually and rely on P2P
// 1p1c package relay instead.
SubmitPackage(txns []*wire.MsgTx,
maxFeeRate *chainfee.SatPerVByte) (*btcjson.SubmitPackageResult,
error)
// LabelTransaction adds a label to a transaction. If the tx already
// has a label, this call will fail unless the overwrite parameter
// is set. Labels must not be empty, and they are limited to 500 chars.

View file

@ -7,6 +7,7 @@ import (
"github.com/btcsuite/btcd/address/v2"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/btcutil/v2/hdkeychain"
"github.com/btcsuite/btcd/chaincfg/v2"
@ -256,6 +257,19 @@ func (w *mockWalletController) PublishTransaction(tx *wire.MsgTx,
return nil
}
// SubmitPackage publishes each transaction in the package individually,
// mirroring PublishTransaction. The mock has no real chain backend so it
// returns an empty result.
func (w *mockWalletController) SubmitPackage(txns []*wire.MsgTx,
_ *chainfee.SatPerVByte) (*btcjson.SubmitPackageResult, error) {
for _, tx := range txns {
w.PublishedTransactions <- tx
}
return &btcjson.SubmitPackageResult{}, nil
}
// GetTransactionDetails currently does nothing.
func (w *mockWalletController) GetTransactionDetails(*chainhash.Hash) (
*TransactionDetail, error) {