Skip to content

Commit fc3c2b4

Browse files
committed
chain: add SubmitPackage API for atomic parents+child relay
This commit introduces first-class support for Bitcoin Core's `submitpackage` RPC (v26+), allowing btcwallet users to broadcast (potentially zero‑fee) unconfirmed parent txns together with its (fee‑paying) child in one call.
1 parent b26f4ec commit fc3c2b4

File tree

7 files changed

+227
-22
lines changed

7 files changed

+227
-22
lines changed

chain/bitcoind_client.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package chain
22

33
import (
4+
"bytes"
45
"container/list"
56
"encoding/hex"
7+
"encoding/json"
68
"errors"
79
"fmt"
810
"sync"
@@ -1335,6 +1337,130 @@ func (c *BitcoindClient) LookupInputMempoolSpend(op wire.OutPoint) (
13351337
return c.chainConn.events.LookupInputSpend(op)
13361338
}
13371339

1340+
// SubmitPackage broadcasts a parents+child package atomically.
1341+
//
1342+
// `parents` must contain at least one transaction. The returned slice
1343+
// holds the txids of every transaction that entered the mempool.
1344+
//
1345+
// `maxFeeRateBTCPerVByte` caps the effective feerate the node will
1346+
// accept for the entire package, expressed in BTC per virtual byte.
1347+
// Pass nil to leave the limit unset.
1348+
//
1349+
// If the active backend cannot relay packages, ErrUnimplemented is
1350+
// returned.
1351+
func (c *BitcoindClient) SubmitPackage(parents []*wire.MsgTx, child *wire.MsgTx,
1352+
maxFeeRateBTCPerVByte *float64) ([]*chainhash.Hash, error) {
1353+
1354+
// Sanity check inputs.
1355+
if len(parents) == 0 {
1356+
return nil, errors.New("submitpackage: need at least one " +
1357+
"parent txn")
1358+
}
1359+
1360+
if child == nil {
1361+
return nil, errors.New("submitpackage: child txn not defined")
1362+
}
1363+
1364+
// Prepare the hex encoded transactions to that we'll add the the
1365+
// request.
1366+
toHex := func(tx *wire.MsgTx) (string, error) {
1367+
var buf bytes.Buffer
1368+
if err := tx.Serialize(&buf); err != nil {
1369+
return "", err
1370+
}
1371+
1372+
return hex.EncodeToString(buf.Bytes()), nil
1373+
}
1374+
1375+
rawTxs := make([]string, 0, len(parents)+1)
1376+
1377+
for i, ch := range parents {
1378+
h, err := toHex(ch)
1379+
if err != nil {
1380+
return nil, fmt.Errorf("cannot serialize parent txn "+
1381+
"%d: %w", i, err)
1382+
}
1383+
1384+
rawTxs = append(rawTxs, h)
1385+
}
1386+
1387+
h, err := toHex(child)
1388+
if err != nil {
1389+
return nil, fmt.Errorf("cannot serialize child txn: %w", err)
1390+
}
1391+
1392+
rawTxs = append(rawTxs, h)
1393+
1394+
// Parameter 1: The package (array of hex strings).
1395+
rawTxsJSON, err := json.Marshal(rawTxs)
1396+
if err != nil {
1397+
return nil, fmt.Errorf("failed to marshal raw txs array: %w",
1398+
err)
1399+
}
1400+
1401+
params := []json.RawMessage{rawTxsJSON}
1402+
1403+
// Parameter 2: maxfeerate (optional, numeric, in BTC/kvB)
1404+
if maxFeeRateBTCPerVByte != nil {
1405+
// Convert BTC/vByte to BTC/kvB.
1406+
maxFeeRateBTCPerKvB := *maxFeeRateBTCPerVByte * 1000
1407+
maxFeeRateJSON, err := json.Marshal(maxFeeRateBTCPerKvB)
1408+
if err != nil {
1409+
return nil, fmt.Errorf("failed to marshal "+
1410+
"maxfeerate: %w", err)
1411+
}
1412+
1413+
params = append(params, maxFeeRateJSON)
1414+
}
1415+
1416+
resp, err := c.chainConn.client.RawRequest("submitpackage", params)
1417+
if err != nil {
1418+
if isMethodNotFound(err) {
1419+
return nil, ErrUnimplemented
1420+
}
1421+
return nil, err
1422+
}
1423+
1424+
// Unmarshall the response.
1425+
var result btcjson.SubmitPackageResult
1426+
if err := json.Unmarshal(resp, &result); err != nil {
1427+
return nil, err
1428+
}
1429+
1430+
submittedIDs := make([]*chainhash.Hash, 0, len(result.TxResults))
1431+
for _, txRes := range result.TxResults {
1432+
if txRes.Error != nil {
1433+
// Skip rejected transactions.
1434+
continue
1435+
}
1436+
1437+
if txRes.OtherWtxid != nil {
1438+
// This is a child transaction that was already
1439+
// in the mempool.
1440+
continue
1441+
}
1442+
1443+
h, err := chainhash.NewHashFromStr(txRes.TxID)
1444+
if err != nil {
1445+
// Note that this should never happen.
1446+
return nil, fmt.Errorf("failed to parse txid '%s' "+
1447+
"from submitpackage result: %w", txRes.TxID,
1448+
err)
1449+
}
1450+
1451+
submittedIDs = append(submittedIDs, h)
1452+
}
1453+
1454+
return submittedIDs, nil
1455+
}
1456+
1457+
// isMethodNotFound returns true if err is exactly the JSON‑RPC "method not
1458+
// found" error, regardless of the transport.
1459+
func isMethodNotFound(err error) bool {
1460+
rpcErr, ok := err.(*btcjson.RPCError)
1461+
return ok && rpcErr.Code == btcjson.ErrRPCMethodNotFound.Code
1462+
}
1463+
13381464
// resetWatchedFilters empties the maps used to track outpoints, addresses, and
13391465
// txns.
13401466
func (c *BitcoindClient) resetWatchedFilters() {

chain/btcd.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,3 +639,11 @@ func (c *RPCClient) SendRawTransaction(tx *wire.MsgTx,
639639

640640
return txid, nil
641641
}
642+
643+
// SubmitPackage is currently unimplemented for btcd. The method is added to
644+
// satisfy the chain.Interface interface.
645+
func (c *RPCClient) SubmitPackage(_ []*wire.MsgTx, _ *wire.MsgTx,
646+
maxFeeRateBTCPerVByte *float64) ([]*chainhash.Hash, error) {
647+
648+
return nil, ErrUnimplemented
649+
}

chain/interface.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ type Interface interface {
4949
Notifications() <-chan interface{}
5050
BackEnd() string
5151
TestMempoolAccept([]*wire.MsgTx, float64) ([]*btcjson.TestMempoolAcceptResult, error)
52+
53+
// SubmitPackage broadcasts a parents+child package atomically.
54+
//
55+
// `parents` must contain at least one transaction. The returned slice
56+
// holds the txids of every transaction that entered the mempool.
57+
//
58+
// `maxFeeRateBTCPerVByte` caps the effective feerate the node will
59+
// accept for the entire package, expressed in BTC per virtual byte.
60+
// Pass nil to leave the limit unset.
61+
//
62+
// If the active backend cannot relay packages, ErrUnimplemented is
63+
// returned.
64+
SubmitPackage(parents []*wire.MsgTx, child *wire.MsgTx,
65+
maxFeeRateBTCPerVByte *float64) ([]*chainhash.Hash, error)
66+
5267
MapRPCErr(err error) error
5368
}
5469

chain/neutrino.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,3 +844,11 @@ func (s *NeutrinoClient) MapRPCErr(rpcErr error) error {
844844
// If not matched, return the original error wrapped.
845845
return fmt.Errorf("%w: %v", ErrUndefined, rpcErr)
846846
}
847+
848+
// SubmitPackage is currently unimplemented for neutrino. The method is added to
849+
// satisfy the chain.Interface interface.
850+
func (s *NeutrinoClient) SubmitPackage(_ []*wire.MsgTx, _ *wire.MsgTx,
851+
maxFeeRateBTCPerVByte *float64) ([]*chainhash.Hash, error) {
852+
853+
return nil, ErrUnimplemented
854+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,7 @@ require (
5353
gopkg.in/yaml.v3 v3.0.1 // indirect
5454
)
5555

56+
// TODO(bhandras): remove this replace once the upstream PR is merged
57+
replace github.com/btcsuite/btcd => github.com/bhandras/btcd v0.22.0-beta.0.20250505192317-88de59959669
58+
5659
go 1.22

0 commit comments

Comments
 (0)