mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-13 12:32:48 +02:00
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:
parent
0dbe2b1029
commit
f55c0565d0
17 changed files with 1019 additions and 215 deletions
|
|
@ -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
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: "*"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
38
lnwallet/btcwallet/submitpackage_test.go
Normal file
38
lnwallet/btcwallet/submitpackage_test.go
Normal 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue