Configure bitcoind test instances to use bech32m addresses (#3195)
What changed, and why it matters
This commit updates Eclair's Bitcoin Core integration to use taproot (bech32m) addresses by default and fixes a fee-calculation bug where the anchor output amount was not being counted. The fee bug caused on-chain fees to be undercounted by 330 satoshis, which was previously hidden by test tolerances but surfaced when switching to smaller taproot inputs. The fix is in the ReplaceableTxFunder component, which funds transactions for force-closing Lightning channels. There is no evidence this was a remotely exploitable vulnerability; it is best characterized as a correctness/robustness fix that could lead to slightly overpaying on-chain fees.
Review whether the corrected fee computation affects any live fee-bumping or RBF logic in production; ensure deployments using Bitcoin Core 29.0+ with bech32m are tested. No urgent security patch appears required, but operators should verify their bitcoind wallet uses segwit/bech32m UTXOs as documented.
Security signals we found
Fee calculation bug fixed in ReplaceableTxFunder.scala
Anchor output amount (330 sats) now included in currentFee computation
Default bitcoind address type changed to bech32m/taproot
Bitcoin Core minimum version relaxed from 29.1 to 29.0
Test tolerances and UTXO amounts adjusted for p2tr weights
Evidence from the diff
The commit changes default bitcoind address/change types from bech32 (segwit v0) to bech32m (taproot/segwit v1) in README, Setup version check, and test fixtures. The substantive production code change is in ReplaceableTxFunder.scala: when computing the current fee for a replaceable package (commit tx + anchor tx + wallet-funded tx), the code now adds anchorTx.input.txOut.amount to currentFee because the anchor output amount had not yet been accounted for. This prevented the change output from being computed correctly. The remaining diff adjusts tests and feerate tolerances to accommodate p2tr input weights and the corrected fee math.
Changed components
fr.acinq.eclair.channel.publish.ReplaceableTxFunderfr.acinq.eclair.SetupREADME.mdBitcoindService test fixtureBitcoinCoreClientSpecInteractiveTxBuilderSpecReplaceableTxPublisherSpecInspect captured patch +25 / −24
diff --git a/README.md b/README.md
index 74c8a74..98c5409 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ This means that instead of re-implementing them, Eclair benefits from the verifi
* Eclair needs a _synchronized_, _segwit-ready_, **_zeromq-enabled_**, _wallet-enabled_, _non-pruning_, _tx-indexing_ [Bitcoin Core](https://github.com/bitcoin/bitcoin) node.
* You must configure your Bitcoin node to use `bech32` or `bech32m` (segwit) addresses. If your wallet has "non-segwit UTXOs" (outputs that are neither `p2sh-segwit`, `bech32` or `bech32m`), you must send them to a `bech32` or `bech32m` address before running Eclair.
-* Eclair requires Bitcoin Core 29.1 or higher. If you are upgrading an existing wallet, you may need to create a new address and send all your funds to that address.
+* Eclair requires Bitcoin Core 29 or higher. If you are upgrading an existing wallet, you may need to create a new address and send all your funds to that address.
Run bitcoind with the following minimal `bitcoin.conf`:
@@ -71,8 +71,8 @@ server=1
rpcuser=foo
rpcpassword=bar
txindex=1
-addresstype=bech32
-changetype=bech32
+addresstype=bech32m
+changetype=bech32m
zmqpubhashblock=tcp://127.0.0.1:29000
zmqpubrawtx=tcp://127.0.0.1:29000
```
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
index e0e40ba..4dd9fc4 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
@@ -195,7 +195,7 @@ class Setup(val datadir: File,
await(getBitcoinStatus(bitcoinClient), 30 seconds, "bitcoind did not respond after 30 seconds")
}
logger.info(s"bitcoind version=${bitcoinStatus.version}")
- assert(bitcoinStatus.version >= 290100, "Eclair requires Bitcoin Core 29.1 or higher")
+ assert(bitcoinStatus.version >= 290000, "Eclair requires Bitcoin Core 29 or higher")
bitcoinStatus.unspentAddresses.foreach { address =>
val isSegwit = addressToPublicKeyScript(bitcoinStatus.chainHash, address).map(script => Script.isNativeWitnessScript(script)).getOrElse(false)
assert(isSegwit, s"Your wallet contains non-segwit UTXOs (e.g. address=$address). You must send those UTXOs to a segwit address to use Eclair (check out our README for more details).")
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala
index 057113e..8d7acc3 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala
@@ -369,7 +369,8 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR
// pay the expected package feerate.
val packageWeight = commitTx.weight() + anchorTx.commitmentFormat.anchorInputWeight + fundTxResponse.tx.weight()
val expectedFee = Transactions.weight2fee(targetFeerate, packageWeight)
- val currentFee = commitFee + fundTxResponse.fee
+ // Note that we haven't taken into account yet the amount of the anchor output, so we add it here.
+ val currentFee = commitFee + fundTxResponse.fee + anchorTx.input.txOut.amount
val changeAmount = (fundTxResponse.tx.txOut.map(_.amount).sum - expectedFee + currentFee).max(dustLimit)
WalletInputs(walletInputs, changeOutput_opt = Some(TxOut(changeAmount, changeScript)))
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala
index 11db58f..f59e6cc 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala
@@ -54,7 +54,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
implicit val formats: Formats = DefaultFormats
- val defaultAddressType_opt: Option[String] = Some("bech32")
+ val defaultAddressType_opt: Option[String] = Some("bech32m")
override def beforeAll(): Unit = {
// Note that we don't specify a default change address type, allowing bitcoind to choose between p2wpkh and p2tr.
@@ -90,10 +90,10 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
val sender = TestProbe()
val bitcoinClient = makeBitcoinCoreClient()
- // wallet is configured with address_type=bech32
+ // wallet is configured with address_type=bech32m
bitcoinClient.getReceiveAddress(None).pipeTo(sender.ref)
val address = sender.expectMsgType[String]
- assert(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).map(Script.isPay2wpkh).contains(true))
+ assert(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).map(Script.isPay2tr).contains(true))
bitcoinClient.getReceiveAddress(Some(AddressType.P2wpkh)).pipeTo(sender.ref)
val address1 = sender.expectMsgType[String]
@@ -108,10 +108,10 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
val sender = TestProbe()
val bitcoinClient = makeBitcoinCoreClient()
- // wallet is configured with address_type=bech32
+ // wallet is configured with address_type=bech32m
bitcoinClient.getChangeAddress(None).pipeTo(sender.ref)
val address = sender.expectMsgType[String]
- assert(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).map(Script.isPay2wpkh).contains(true))
+ assert(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).map(Script.isPay2tr).contains(true))
bitcoinClient.getChangeAddress(Some(AddressType.P2wpkh)).pipeTo(sender.ref)
val address1 = sender.expectMsgType[String]
@@ -1746,11 +1746,11 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
val sender = TestProbe()
val bitcoinClient = makeBitcoinCoreClient()
- // eclair on-chain key manager does not yet support taproot descriptors
+ // eclair on-chain key manager supports taproot descriptors
bitcoinClient.getReceiveAddress().pipeTo(sender.ref)
val defaultAddress = sender.expectMsgType[String]
val decoded = Bech32.decodeWitnessAddress(defaultAddress)
- assert(decoded.getSecond == 0)
+ assert(decoded.getSecond == 1)
// But we can explicitly use segwit v0 addresses.
bitcoinClient.getP2wpkhPubkey().pipeTo(sender.ref)
@@ -2015,13 +2015,13 @@ class BitcoinCoreClientWithEclairSignerSpec extends BitcoinCoreClientSpec {
assert(wallet.onChainKeyManager_opt.get.masterPubKey(0, AddressType.P2wpkh) == accountXPub)
(0 to 10).foreach { _ =>
- wallet.getReceiveAddress().pipeTo(sender.ref)
+ wallet.getReceiveAddress(Some(AddressType.P2wpkh)).pipeTo(sender.ref)
val address = sender.expectMsgType[String]
val bip32path = getBip32Path(wallet, address, sender)
assert(bip32path.path.length == 5 && bip32path.toString().startsWith("m/84'/1'/0'/0"))
assert(computeBIP84Address(master.derivePrivateKey(bip32path).publicKey, Block.RegtestGenesisBlock.hash) == address)
- wallet.getChangeAddress().pipeTo(sender.ref)
+ wallet.getChangeAddress(Some(AddressType.P2wpkh)).pipeTo(sender.ref)
val changeAddress = sender.expectMsgType[String]
val bip32ChangePath = getBip32Path(wallet, changeAddress, sender)
assert(bip32ChangePath.path.length == 5 && bip32ChangePath.toString().startsWith("m/84'/1'/0'/1"))
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala
index afe2fce..7b6d3b8 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala
@@ -89,8 +89,8 @@ trait BitcoindService extends Logging {
val onChainKeyManager = new LocalOnChainKeyManager("eclair", MnemonicCode.toSeed(mnemonics, passphrase), TimestampSecond.now(), Block.RegtestGenesisBlock.hash)
def startBitcoind(useCookie: Boolean = false,
- defaultAddressType_opt: Option[String] = None,
- changeAddressType_opt: Option[String] = None,
+ defaultAddressType_opt: Option[String] = Some("bech32m"),
+ changeAddressType_opt: Option[String] = Some("bech32m"),
mempoolSize_opt: Option[Int] = None, // mempool size in MB
mempoolMinFeerate_opt: Option[FeeratePerByte] = None, // transactions below this feerate won't be accepted in the mempool
startupFlags: String = ""): Unit = {
@@ -199,7 +199,7 @@ trait BitcoindService extends Logging {
val addressToUse = address match {
case Some(addr) => addr
case None =>
- sender.send(bitcoincli, BitcoinReq("getnewaddress", "", "bech32"))
+ sender.send(bitcoincli, BitcoinReq("getnewaddress", "", "bech32m"))
val JString(address) = sender.expectMsgType[JValue](timeout)
address
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
index 9d27115..d71ae5c 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
@@ -77,7 +77,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
}
private def createInput(channelId: ByteVector32, serialId: UInt64, amount: Satoshi): TxAddInput = {
- val changeScript = Script.write(Script.pay2wpkh(randomKey().publicKey))
+ val changeScript = Script.write(Script.pay2tr(randomKey().publicKey.xOnly))
val previousTx = Transaction(2, Nil, Seq(TxOut(amount, changeScript), TxOut(amount, changeScript), TxOut(amount, changeScript)), 0)
TxAddInput(channelId, serialId, Some(previousTx), 1, 0)
}
@@ -1463,7 +1463,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
}
test("fund transaction with previous inputs (with new inputs)") {
- val targetFeerate = FeeratePerKw(10_000 sat)
+ val targetFeerate = FeeratePerKw(11_000 sat)
val fundingA = 100_000 sat
val utxosA = Seq(55_000 sat, 55_000 sat, 55_000 sat)
withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f =>
@@ -1547,7 +1547,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
val fundingA = 100_000 sat
val utxosA = Seq(70_000 sat, 60_000 sat)
val fundingB = 25_000 sat
- val utxosB = Seq(27_500 sat)
+ val utxosB = Seq(27_250 sat)
withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, fundingB, utxosB, initialFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f =>
import f._
@@ -1892,7 +1892,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
val targetFeerate = FeeratePerKw(10_000 sat)
val fundingA = 100_000 sat
val utxosA = Seq(150_000 sat)
- val fundingB = 92_000 sat
+ val fundingB = 93_000 sat
val utxosB = Seq(50_000 sat, 50_000 sat, 50_000 sat, 50_000 sat)
withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f =>
import f._
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala
index b8d84e5..60c1d74 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala
@@ -1119,7 +1119,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess)
val htlcSuccessTx = getMempoolTxs(1).head
val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx.weight.toInt)
- assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.2, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee")
+ assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee")
assert(htlcSuccessTx.fees <= htlcSuccess.txInfo.amountIn)
generateBlocks(6)
@@ -1147,7 +1147,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
setFeerate(targetFeerate) // the feerate is higher than what it was when the channel force-closed
val htlcTimeoutTx = getMempoolTxs(1).head
val htlcTimeoutTargetFee = Transactions.weight2fee(targetFeerate, htlcTimeoutTx.weight.toInt)
- assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.2, s"actualFee=${htlcTimeoutTx.fees} targetFee=$htlcTimeoutTargetFee")
+ assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcTimeoutTx.fees} targetFee=$htlcTimeoutTargetFee")
assert(htlcTimeoutTx.fees <= htlcTimeout.txInfo.amountIn)
generateBlocks(6)
@@ -1264,7 +1264,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f =>
import f._
- val targetFeerate = FeeratePerKw(8_000 sat)
+ val targetFeerate = FeeratePerKw(10_000 sat)
val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30)
// NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided.
setFeerate(targetFeerate, blockTarget = 12)
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.