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