From ccaeeb964e70d266e2ed03de305bbfc83a8a8fb2 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Thu, 31 Jul 2025 15:03:06 +0200 Subject: [PATCH 1/4] itest: run normal FundPsbt test case in remote-signer mode This makes sure there is no general issue with running the pay-join FundPsbt test case in a remote signer setup. --- itest/lnd_psbt_test.go | 8 ++++++++ itest/lnd_remote_signer_test.go | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/itest/lnd_psbt_test.go b/itest/lnd_psbt_test.go index 6090784a0..7dd5af926 100644 --- a/itest/lnd_psbt_test.go +++ b/itest/lnd_psbt_test.go @@ -1060,6 +1060,14 @@ func testFundPsbt(ht *lntest.HarnessTest) { alice := ht.NewNodeWithCoins("Alice", nil) bob := ht.NewNodeWithCoins("Bob", nil) + runFundPsbt(ht, alice, bob) +} + +// runFundPsbt tests the FundPsbt RPC use case where we want to fund a PSBT +// that already has an input specified. This is a pay-join scenario where Bob +// wants to send Alice some coins, but he wants to do so in a way that doesn't +// reveal the full amount he is sending. +func runFundPsbt(ht *lntest.HarnessTest, alice, bob *node.HarnessNode) { // We test a pay-join between Alice and Bob. Bob wants to send Alice // 5 million Satoshis in a non-obvious way. So Bob selects a UTXO that's // bigger than 5 million Satoshis and expects the change minus the send diff --git a/itest/lnd_remote_signer_test.go b/itest/lnd_remote_signer_test.go index fd48df643..80f936dd0 100644 --- a/itest/lnd_remote_signer_test.go +++ b/itest/lnd_remote_signer_test.go @@ -328,6 +328,11 @@ func testRemoteSignerPSBT(ht *lntest.HarnessTest) { // that aren't in the wallet. But we also want to make // sure we can fund and then sign PSBTs from our wallet. runFundAndSignPsbt(ht, wo) + + // We also have a more specific funding test that does + // a pay-join payment with Carol. + ht.FundCoins(btcutil.SatoshiPerBitcoin, carol) + runFundPsbt(ht, wo, carol) }, } From f18ae884b7eb9e6bc5f9471f2ec32865d031aa62 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Thu, 31 Jul 2025 15:03:50 +0200 Subject: [PATCH 2/4] itest: run FundPsbt test with imported account This demonstrates that the "imported" account behaves differently for a wallet that's watch-only vs. normal. The testTaprootImportTapscriptFullKeyFundPsbt test case succeeds in a normal run but fails in a remote-signing setup. For some reason, an imported tapscript address ends up in the "default" account when remote-signing but in the "imported" account for a normal wallet. --- itest/lnd_remote_signer_test.go | 22 ++++++ itest/lnd_taproot_test.go | 130 ++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/itest/lnd_remote_signer_test.go b/itest/lnd_remote_signer_test.go index 80f936dd0..eac22828b 100644 --- a/itest/lnd_remote_signer_test.go +++ b/itest/lnd_remote_signer_test.go @@ -27,6 +27,10 @@ var remoteSignerTestCases = []*lntest.TestCase{ Name: "account import", TestFunc: testRemoteSignerAccountImport, }, + { + Name: "tapscript import", + TestFunc: testRemoteSignerTapscriptImport, + }, { Name: "channel open", TestFunc: testRemoteSignerChannelOpen, @@ -228,6 +232,24 @@ func testRemoteSignerAccountImport(ht *lntest.HarnessTest) { tc.fn(ht, watchOnly, carol) } +func testRemoteSignerTapscriptImport(ht *lntest.HarnessTest) { + tc := remoteSignerTestCase{ + name: "tapscript import", + sendCoins: true, + fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) { + testTaprootImportTapscriptFullTree(ht, wo) + testTaprootImportTapscriptPartialReveal(ht, wo) + testTaprootImportTapscriptRootHashOnly(ht, wo) + testTaprootImportTapscriptFullKey(ht, wo) + + testTaprootImportTapscriptFullKeyFundPsbt(ht, wo) + }, + } + + _, watchOnly, carol := prepareRemoteSignerTest(ht, tc) + tc.fn(ht, watchOnly, carol) +} + func testRemoteSignerChannelOpen(ht *lntest.HarnessTest) { tc := remoteSignerTestCase{ name: "basic channel open close", diff --git a/itest/lnd_taproot_test.go b/itest/lnd_taproot_test.go index ddbfb6983..842102965 100644 --- a/itest/lnd_taproot_test.go +++ b/itest/lnd_taproot_test.go @@ -90,6 +90,8 @@ func testTaprootImportScripts(ht *lntest.HarnessTest) { testTaprootImportTapscriptPartialReveal(ht, alice) testTaprootImportTapscriptRootHashOnly(ht, alice) testTaprootImportTapscriptFullKey(ht, alice) + + testTaprootImportTapscriptFullKeyFundPsbt(ht, alice) } // testTaprootSendCoinsKeySpendBip86 tests sending to and spending from @@ -1359,6 +1361,134 @@ func testTaprootImportTapscriptFullKey(ht *lntest.HarnessTest, ) } +// testTaprootImportTapscriptFullKeyFundPsbt tests importing p2tr script +// addresses for which we only know the full Taproot key. We also test that we +// can use such an imported script to fund a PSBT. +func testTaprootImportTapscriptFullKeyFundPsbt(ht *lntest.HarnessTest, + alice *node.HarnessNode) { + + // For the next step, we need a public key. Let's use a special family + // for this. + _, internalKey, derivationPath := deriveInternalKey(ht, alice) + + // Let's create a taproot script output now. This is a hash lock with a + // simple preimage of "foobar". + leaf1 := testScriptHashLock(ht.T, []byte("foobar")) + + tapscript := input.TapscriptFullTree(internalKey, leaf1) + rootHash := leaf1.TapHash() + taprootKey, err := tapscript.TaprootKey() + require.NoError(ht, err) + + // Import the scripts and make sure we get the same address back as we + // calculated ourselves. + req := &walletrpc.ImportTapscriptRequest{ + InternalPublicKey: schnorr.SerializePubKey(taprootKey), + Script: &walletrpc.ImportTapscriptRequest_FullKeyOnly{ + FullKeyOnly: true, + }, + } + importResp := alice.RPC.ImportTapscript(req) + + calculatedAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(taprootKey), harnessNetParams, + ) + require.NoError(ht, err) + require.Equal(ht, calculatedAddr.String(), importResp.P2TrAddress) + + // Send some coins to the generated tapscript address. + p2trOutpoint, p2trPkScript := sendToTaprootOutput(ht, alice, taprootKey) + + p2trOutputRPC := &lnrpc.OutPoint{ + TxidBytes: p2trOutpoint.Hash[:], + OutputIndex: p2trOutpoint.Index, + } + ht.AssertUTXOInWallet(alice, p2trOutputRPC, "imported") + ht.AssertWalletAccountBalance(alice, "imported", testAmount, 0) + + // We now fund a PSBT that spends the imported tapscript address. + utxo := &wire.TxOut{ + Value: testAmount, + PkScript: p2trPkScript, + } + _, sweepPkScript := newAddrWithScript( + ht, alice, lnrpc.AddressType_WITNESS_PUBKEY_HASH, + ) + + output := &wire.TxOut{ + PkScript: sweepPkScript, + Value: 1, + } + packet, err := psbt.New( + []*wire.OutPoint{&p2trOutpoint}, []*wire.TxOut{output}, 2, 0, + []uint32{0}, + ) + require.NoError(ht, err) + + // We have everything we need to know to sign the PSBT. + in := &packet.Inputs[0] + in.Bip32Derivation = []*psbt.Bip32Derivation{{ + PubKey: internalKey.SerializeCompressed(), + Bip32Path: derivationPath, + }} + in.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{{ + XOnlyPubKey: schnorr.SerializePubKey(internalKey), + Bip32Path: derivationPath, + }} + in.SighashType = txscript.SigHashDefault + in.TaprootMerkleRoot = rootHash[:] + in.WitnessUtxo = utxo + + var buf bytes.Buffer + require.NoError(ht, packet.Serialize(&buf)) + + change := &walletrpc.PsbtCoinSelect_ExistingOutputIndex{ + ExistingOutputIndex: 0, + } + fundResp := alice.RPC.FundPsbt(&walletrpc.FundPsbtRequest{ + Template: &walletrpc.FundPsbtRequest_CoinSelect{ + CoinSelect: &walletrpc.PsbtCoinSelect{ + Psbt: buf.Bytes(), + ChangeOutput: change, + }, + }, + Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{ + SatPerVbyte: 1, + }, + }) + + // Sign the manually funded PSBT now. + signResp := alice.RPC.SignPsbt(&walletrpc.SignPsbtRequest{ + FundedPsbt: fundResp.FundedPsbt, + }) + + signedPacket, err := psbt.NewFromRawBytes( + bytes.NewReader(signResp.SignedPsbt), false, + ) + require.NoError(ht, err) + + // We should be able to finalize the PSBT and extract the sweep TX now. + err = psbt.MaybeFinalizeAll(signedPacket) + require.NoError(ht, err) + + sweepTx, err := psbt.Extract(signedPacket) + require.NoError(ht, err) + + buf.Reset() + err = sweepTx.Serialize(&buf) + require.NoError(ht, err) + + // Publish the sweep transaction and then mine it as well. + alice.RPC.PublishTransaction(&walletrpc.Transaction{ + TxHex: buf.Bytes(), + }) + + // Mine one block which should contain the sweep transaction. + block := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + sweepTxHash := sweepTx.TxHash() + ht.AssertTxInBlock(block, sweepTxHash) +} + // clearWalletImportedTapscriptBalance manually assembles and then attempts to // sign a TX to sweep funds from an imported tapscript address. func clearWalletImportedTapscriptBalance(ht *lntest.HarnessTest, From b5257546f7758521de60c6a177c3b13de4db6e28 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Mon, 4 Aug 2025 12:51:42 +0200 Subject: [PATCH 3/4] mod: update btcwallet to version with fix --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 22f367abc..2bfef4efa 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b - github.com/btcsuite/btcwallet v0.16.14 + github.com/btcsuite/btcwallet v0.16.15-0.20250805011126-a3632ae48ab3 github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 github.com/btcsuite/btcwallet/walletdb v1.5.1 diff --git a/go.sum b/go.sum index 1a634390f..d6d293421 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c/go.mod h1:w7xnGOhw 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/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/btcwallet v0.16.14 h1:CofysgmI1ednkLsXontAdBoXJkbiim7unXnFKhLLjnE= -github.com/btcsuite/btcwallet v0.16.14/go.mod h1:H6dfoZcWPonM2wbVsR2ZBY0PKNZKdQyLAmnX8vL9JFA= +github.com/btcsuite/btcwallet v0.16.15-0.20250805011126-a3632ae48ab3 h1:MAjNRpj3XhCOrhchq4wq0qI34TIBX/DCnT6OLWejx68= +github.com/btcsuite/btcwallet v0.16.15-0.20250805011126-a3632ae48ab3/go.mod h1:H6dfoZcWPonM2wbVsR2ZBY0PKNZKdQyLAmnX8vL9JFA= github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 h1:Rr0njWI3r341nhSPesKQ2JF+ugDSzdPoeckS75SeDZk= github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5/go.mod h1:+tXJ3Ym0nlQc/iHSwW1qzjmPs3ev+UVWMbGgfV1OZqU= github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 h1:YEO+Lx1ZJJAtdRrjuhXjWrYsmAk26wLTlNzxt2q0lhk= From 100feb74161511a4df662e2bc853a93ef42801aa Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Tue, 5 Aug 2025 16:21:26 +0200 Subject: [PATCH 4/4] docs: add release notes --- docs/release-notes/release-notes-0.19.3.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.19.3.md b/docs/release-notes/release-notes-0.19.3.md index f076b5df0..6947f2c51 100644 --- a/docs/release-notes/release-notes-0.19.3.md +++ b/docs/release-notes/release-notes-0.19.3.md @@ -33,6 +33,10 @@ can cause contract resolvers to be stuck at marking the channel force close as being complete. +- [Fixed a bug in `btcwallet` that caused issues with Tapscript addresses being + imported in a watch-only (e.g. remote-signing) + setup](https://github.com/lightningnetwork/lnd/pull/10119). + # New Features ## Functional Enhancements @@ -84,4 +88,5 @@ * Elle Mouton * Olaoluwa Osuntokun +* Oliver Gugger * Yong Yu