Skip to content

Commit 0fcb3fe

Browse files
committed
integration: add submitpackage RPC tests
In this commit, we add integration tests that validate the complete submitpackage RPC functionality from end-to-end using the rpctest harness. These tests verify the critical functionality needed for BIP 431 TRUC transaction relay and package RBF. The test suite covers five essential scenarios. First, basic package submission with a valid 1-parent-1-child v3 package ensures the fundamental flow works. Second, topology validation is tested by submitting transactions in the wrong order (child before parent), verifying that improper packages are rejected with clear error messages. Third, we test BIP 431 Rule 6 support by submitting a package with a zero-fee v3 parent and high-fee v3 child. This verifies that the parent is accepted despite having zero fees because the package as a whole meets the mempool's feerate requirements. This is the critical use case for Lightning Network commitment transactions with anchor outputs. Fourth, we test package RBF where an existing package [A, B] is replaced by [A, B'] where A is deduplicated (already in mempool) and B' replaces the original child B. This tests deduplication logic combined with package-level RBF validation and verifies that the final mempool state contains A + B' with B removed. Fifth and most importantly, we test true package RBF where both transactions conflict. Using the rpctest harness's WithInputs functional option, we create A' that spends the same inputs as A, forcing a double-spend conflict. When package [A', B''] is submitted, our implementation correctly validates the package-level feerate against the existing package, atomically removes both A and B, and accepts both A' and B''. This demonstrates that full package-level RBF works correctly with proper conflict aggregation and atomic replacement. All tests include proper mempool cleanup to ensure isolated test execution, and they verify both the RPC result structure and the actual mempool state to catch bugs where transactions might be marked "accepted" but not actually added to the graph.
1 parent 553a744 commit 0fcb3fe

File tree

1 file changed

+339
-0
lines changed

1 file changed

+339
-0
lines changed

integration/submitpackage_test.go

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
//go:build rpctest
2+
// +build rpctest
3+
4+
package integration
5+
6+
import (
7+
"testing"
8+
9+
"github.com/btcsuite/btcd/chaincfg"
10+
"github.com/btcsuite/btcd/chaincfg/chainhash"
11+
"github.com/btcsuite/btcd/integration/rpctest"
12+
"github.com/btcsuite/btcd/txscript"
13+
"github.com/btcsuite/btcd/wire"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// TestSubmitPackage tests the submitpackage RPC call with various scenarios.
18+
func TestSubmitPackage(t *testing.T) {
19+
btcdCfg := []string{
20+
"--rejectnonstd",
21+
"--debuglevel=debug",
22+
"--usetxmempoolv2", // Enable v2 mempool with package support.
23+
}
24+
r, err := rpctest.New(&chaincfg.SimNetParams, nil, btcdCfg, "")
25+
require.NoError(t, err)
26+
27+
require.NoError(t, r.SetUp(true, 100))
28+
defer func() {
29+
if err := r.TearDown(); err != nil {
30+
t.Logf("TearDown error: %v", err)
31+
}
32+
}()
33+
34+
t.Run("valid 1P1C v3 package", func(t *testing.T) {
35+
// Ensure mempool starts empty by mining any lingering transactions.
36+
mempool, err := r.Client.GetRawMempool()
37+
require.NoError(t, err)
38+
if len(mempool) > 0 {
39+
t.Logf("Clearing %d transactions from mempool", len(mempool))
40+
_, err := r.Client.Generate(1)
41+
require.NoError(t, err)
42+
}
43+
44+
// Verify mempool is now empty.
45+
mempool, err = r.Client.GetRawMempool()
46+
require.NoError(t, err)
47+
require.Empty(t, mempool, "mempool must be empty at test start")
48+
49+
// Create a v3 parent transaction.
50+
parent := createV3Transaction(t, r, 50000, 10)
51+
t.Logf("Created parent: %s", parent.TxHash())
52+
53+
// Create a v3 child spending the parent.
54+
child := createV3Child(t, r, parent, 0, 25000, 20)
55+
t.Logf("Created child: %s", child.TxHash())
56+
57+
// Submit package via RPC.
58+
result, err := r.Client.SubmitPackage([]*wire.MsgTx{parent, child}, nil, nil)
59+
require.NoError(t, err)
60+
61+
// Log package result.
62+
t.Logf("Package result: %s", result.PackageMsg)
63+
64+
// Verify success message.
65+
require.Equal(t, "success", result.PackageMsg)
66+
67+
// Verify both transactions have results.
68+
require.Len(t, result.TxResults, 2)
69+
70+
parentWtxid := parent.WitnessHash().String()
71+
childWtxid := child.WitnessHash().String()
72+
73+
// Verify parent result exists and was accepted.
74+
parentResult, ok := result.TxResults[parentWtxid]
75+
require.True(t, ok, "parent result should be present")
76+
require.NotNil(t, parentResult, "parent result should not be nil")
77+
if parentResult.Error != nil {
78+
t.Fatalf("Parent rejected: %s", *parentResult.Error)
79+
}
80+
require.Equal(t, parent.TxHash().String(), parentResult.TxID)
81+
82+
// Verify child result exists and was accepted.
83+
childResult, ok := result.TxResults[childWtxid]
84+
require.True(t, ok, "child result should be present")
85+
require.NotNil(t, childResult, "child result should not be nil")
86+
if childResult.Error != nil {
87+
t.Fatalf("Child rejected: %s", *childResult.Error)
88+
}
89+
require.Equal(t, child.TxHash().String(), childResult.TxID)
90+
91+
// Verify fees were calculated.
92+
require.NotNil(t, parentResult.Fees)
93+
require.NotNil(t, childResult.Fees)
94+
require.Greater(t, parentResult.Fees.Base, float64(0))
95+
require.Greater(t, childResult.Fees.Base, float64(0))
96+
97+
// CRITICAL: Verify transactions are actually in mempool.
98+
mempool, err = r.Client.GetRawMempool()
99+
require.NoError(t, err)
100+
101+
t.Logf("Mempool now has %d transactions:", len(mempool))
102+
for _, hash := range mempool {
103+
t.Logf(" - %s", hash)
104+
}
105+
106+
// Convert to map for proper comparison (mempool returns []*chainhash.Hash).
107+
mempoolMap := make(map[chainhash.Hash]bool)
108+
for _, hash := range mempool {
109+
mempoolMap[*hash] = true
110+
}
111+
112+
require.True(t, mempoolMap[parent.TxHash()],
113+
"BUG: parent %s marked accepted but not in mempool!", parent.TxHash())
114+
require.True(t, mempoolMap[child.TxHash()],
115+
"BUG: child %s marked accepted but not in mempool!", child.TxHash())
116+
})
117+
118+
t.Run("package with invalid topology rejected", func(t *testing.T) {
119+
// Create parent and child.
120+
parent := createV3Transaction(t, r, 50000, 10)
121+
child := createV3Child(t, r, parent, 0, 25000, 20)
122+
123+
// Submit in wrong order (child before parent).
124+
_, err := r.Client.SubmitPackage([]*wire.MsgTx{child, parent}, nil, nil)
125+
require.Error(t, err, "should reject package with bad topology")
126+
require.Contains(t, err.Error(), "topologically sorted")
127+
})
128+
129+
t.Run("zero-fee v3 parent with high-fee child (BIP 431 Rule 6)", func(t *testing.T) {
130+
// Clear mempool before test.
131+
mempool, err := r.Client.GetRawMempool()
132+
require.NoError(t, err)
133+
if len(mempool) > 0 {
134+
_, err = r.Client.Generate(1)
135+
require.NoError(t, err)
136+
}
137+
138+
// Create a zero-fee v3 parent.
139+
parent := createV3Transaction(t, r, 50000, 0)
140+
141+
// Create high-fee v3 child to pay for both (CPFP).
142+
// Child fee should be enough to cover both txs at min relay rate.
143+
child := createV3Child(t, r, parent, 0, 25000, 30)
144+
145+
// Submit package - should succeed due to BIP 431 Rule 6.
146+
result, err := r.Client.SubmitPackage([]*wire.MsgTx{parent, child}, nil, nil)
147+
require.NoError(t, err,
148+
"BIP 431 Rule 6: zero-fee TRUC parent should be accepted in package")
149+
150+
require.Equal(t, "success", result.PackageMsg)
151+
152+
// Verify parent was accepted despite zero fee (BIP 431 Rule 6).
153+
parentWtxid := parent.WitnessHash().String()
154+
parentResult := result.TxResults[parentWtxid]
155+
require.NotNil(t, parentResult)
156+
require.Nil(t, parentResult.Error, "zero-fee TRUC parent should be accepted")
157+
require.Equal(t, parent.TxHash().String(), parentResult.TxID)
158+
159+
// Verify child was accepted.
160+
childWtxid := child.WitnessHash().String()
161+
childResult := result.TxResults[childWtxid]
162+
require.NotNil(t, childResult)
163+
require.Nil(t, childResult.Error)
164+
require.Greater(t, childResult.Fees.Base, float64(0), "child should have non-zero fee")
165+
})
166+
167+
t.Run("package RBF replacement [A,B] → [A,B']", func(t *testing.T) {
168+
// Clear mempool.
169+
mempool, err := r.Client.GetRawMempool()
170+
require.NoError(t, err)
171+
if len(mempool) > 0 {
172+
_, err = r.Client.Generate(1)
173+
require.NoError(t, err)
174+
}
175+
176+
// Step 1: Submit package [A, B] where A has zero fee, B pays modestly.
177+
// A creates a LARGE output so B' can pay substantial fees.
178+
parentA := createV3Transaction(t, r, 5000000, 0) // Zero fee, 5M sat output
179+
childB := createV3Child(t, r, parentA, 0, 4900000, 5) // Low fee rate initially
180+
181+
result1, err := r.Client.SubmitPackage([]*wire.MsgTx{parentA, childB}, nil, nil)
182+
require.NoError(t, err)
183+
require.Equal(t, "success", result1.PackageMsg)
184+
185+
// Verify both in mempool.
186+
mempool, err = r.Client.GetRawMempool()
187+
require.NoError(t, err)
188+
mempoolMap := make(map[chainhash.Hash]bool)
189+
for _, hash := range mempool {
190+
mempoolMap[*hash] = true
191+
}
192+
require.True(t, mempoolMap[parentA.TxHash()], "A should be in mempool")
193+
require.True(t, mempoolMap[childB.TxHash()], "B should be in mempool")
194+
195+
// Step 2: Create B' that spends same output from A but pays MUCH higher fee.
196+
// B' conflicts with B (spends same parent output).
197+
// For package RBF to succeed: package [A, B'] fee rate must beat B's rate.
198+
// A: ~225 vbytes, 0 fee
199+
// B: ~190 vbytes, ~5*190 = ~950 sats, rate ~5000 sat/kvB
200+
// Package [A, B']: (0 + B'_fee) / (225 + 190) * 1000 > B's ~5000 sat/kvB
201+
// B'_fee > 5000 * 415 / 1000 = 2075 sats
202+
// Use much higher to ensure passing: B' outputs 4800000, fee = 100000 sats
203+
childBPrime := createV3Child(t, r, parentA, 0, 4700000, 1000) // Extremely high fee for RBF
204+
205+
// Step 3: Submit package [A, B'] - should succeed via package RBF.
206+
result2, err := r.Client.SubmitPackage([]*wire.MsgTx{parentA, childBPrime}, nil, nil)
207+
require.NoError(t, err, "Package RBF should succeed with sufficient fees")
208+
require.Equal(t, "success", result2.PackageMsg)
209+
210+
// Verify A was deduplicated (marked accepted).
211+
aWtxid := parentA.WitnessHash().String()
212+
aResult := result2.TxResults[aWtxid]
213+
require.NotNil(t, aResult)
214+
require.Nil(t, aResult.Error, "A should be accepted (deduplication)")
215+
216+
// Verify B' was accepted.
217+
bPrimeWtxid := childBPrime.WitnessHash().String()
218+
bPrimeResult := result2.TxResults[bPrimeWtxid]
219+
require.NotNil(t, bPrimeResult)
220+
require.Nil(t, bPrimeResult.Error, "B' should be accepted via individual RBF")
221+
222+
// Step 4: Verify final mempool state - should have A + B', not B.
223+
mempool, err = r.Client.GetRawMempool()
224+
require.NoError(t, err)
225+
mempoolMap = make(map[chainhash.Hash]bool)
226+
for _, hash := range mempool {
227+
mempoolMap[*hash] = true
228+
}
229+
230+
require.True(t, mempoolMap[parentA.TxHash()],
231+
"A should still be in mempool")
232+
require.False(t, mempoolMap[childB.TxHash()],
233+
"B should be replaced (removed from mempool)")
234+
require.True(t, mempoolMap[childBPrime.TxHash()],
235+
"B' should be in mempool (replaced B)")
236+
237+
t.Logf("✅ Package RBF successfully replaced B with B'")
238+
t.Logf(" Original package: A (zero-fee) + B (low fee)")
239+
t.Logf(" Replacement package: A (deduplicated) + B' (high fee)")
240+
t.Logf(" Mechanism: Package-level RBF validation + atomic replacement")
241+
})
242+
243+
t.Run("true package RBF [A,B] → [A',B''] (both conflict)", func(t *testing.T) {
244+
// Clear mempool.
245+
mempool, err := r.Client.GetRawMempool()
246+
require.NoError(t, err)
247+
if len(mempool) > 0 {
248+
_, err = r.Client.Generate(1)
249+
require.NoError(t, err)
250+
}
251+
252+
// Step 1: Submit original package [A, B] with modest fees.
253+
parentA := createV3Transaction(t, r, 5000000, 10)
254+
255+
// Collect A's inputs to create conflicting A'.
256+
aInputs := make([]wire.OutPoint, len(parentA.TxIn))
257+
for i, txIn := range parentA.TxIn {
258+
aInputs[i] = txIn.PreviousOutPoint
259+
}
260+
261+
childB := createV3Child(t, r, parentA, 0, 4900000, 10)
262+
263+
result1, err := r.Client.SubmitPackage([]*wire.MsgTx{parentA, childB}, nil, nil)
264+
require.NoError(t, err)
265+
require.Equal(t, "success", result1.PackageMsg)
266+
267+
// Verify both in mempool.
268+
mempool, err = r.Client.GetRawMempool()
269+
require.NoError(t, err)
270+
mempoolMap := make(map[chainhash.Hash]bool)
271+
for _, hash := range mempool {
272+
mempoolMap[*hash] = true
273+
}
274+
require.True(t, mempoolMap[parentA.TxHash()], "A should be in mempool")
275+
require.True(t, mempoolMap[childB.TxHash()], "B should be in mempool")
276+
277+
// Step 2: Create A' that spends the SAME inputs as A (creating a conflict).
278+
// Use CreateTransaction with WithInputs to force same inputs, wallet signs it.
279+
aPrimeAddr, err := r.NewAddress()
280+
require.NoError(t, err)
281+
aPrimeScript, err := txscript.PayToAddrScript(aPrimeAddr)
282+
require.NoError(t, err)
283+
284+
aPrimeOutput := &wire.TxOut{
285+
Value: 4800000, // Lower output = higher fee
286+
PkScript: aPrimeScript,
287+
}
288+
289+
// Create A' spending same inputs as A with higher fee.
290+
parentAPrime, err := r.CreateTransaction(
291+
[]*wire.TxOut{aPrimeOutput},
292+
500, // High fee rate
293+
false, // No change
294+
rpctest.WithTxVersion(3),
295+
rpctest.WithInputs(aInputs), // Force same inputs as A!
296+
)
297+
require.NoError(t, err)
298+
299+
// Create B'' spending A'.
300+
childBDoublePrime := createV3Child(t, r, parentAPrime, 0, 4700000, 500)
301+
302+
// Step 3: Submit replacement package [A', B''].
303+
result2, err := r.Client.SubmitPackage([]*wire.MsgTx{parentAPrime, childBDoublePrime}, nil, nil)
304+
require.NoError(t, err, "RPC call should succeed")
305+
306+
// Debug: Check for errors.
307+
for wtxid, txRes := range result2.TxResults {
308+
if txRes.Error != nil {
309+
t.Logf("TX %s: REJECTED - %v", wtxid, *txRes.Error)
310+
}
311+
}
312+
313+
require.Equal(t, "success", result2.PackageMsg,
314+
"Package should be accepted, got: %s", result2.PackageMsg)
315+
316+
// Step 4: Verify final mempool state.
317+
mempool, err = r.Client.GetRawMempool()
318+
require.NoError(t, err)
319+
mempoolMap = make(map[chainhash.Hash]bool)
320+
for _, hash := range mempool {
321+
mempoolMap[*hash] = true
322+
}
323+
324+
// Original package should be completely replaced.
325+
require.False(t, mempoolMap[parentA.TxHash()],
326+
"Original A should be replaced by A'")
327+
require.False(t, mempoolMap[childB.TxHash()],
328+
"Original B should be replaced")
329+
330+
// Replacement package should be present.
331+
require.True(t, mempoolMap[parentAPrime.TxHash()],
332+
"A' should be in mempool")
333+
require.True(t, mempoolMap[childBDoublePrime.TxHash()],
334+
"B'' should be in mempool")
335+
336+
t.Logf("✅ True package RBF: [A,B] fully replaced by [A',B'']")
337+
t.Logf(" Replaced %d transactions", len(result2.ReplacedTransactions))
338+
})
339+
}

0 commit comments

Comments
 (0)