Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions itest/list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ var allTestCases = []*lntest.TestCase{
Name: "fund psbt custom lock",
TestFunc: testFundPsbtCustomLock,
},
{
Name: "fund psbt taproot script path",
TestFunc: testFundPsbtTaprootScriptPath,
},
{
Name: "resolution handoff",
TestFunc: testResHandoff,
Expand Down
142 changes: 142 additions & 0 deletions itest/lnd_psbt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2038,3 +2038,145 @@
leasesRespAfter := alice.RPC.ListLeases()
require.Empty(ht, leasesRespAfter.LockedUtxos)
}

// testFundPsbtTaprootScriptPath tests that FundPsbt can correctly estimate fees
// when the PSBT contains inputs that spend via a taproot script path.
// Previously, FundPsbt would return an error for script path spends because
// the weight estimation didn't support them. This test verifies the fix.
func testFundPsbtTaprootScriptPath(ht *lntest.HarnessTest) {
alice := ht.NewNodeWithCoins("Alice", nil)

// Derive the signing key and its derivation path.
keyDesc, leafSigningKey, derivationPath := deriveInternalKey(ht, alice)

// Create a simple OP_CHECKSIG tapscript leaf.
leaf := testScriptSchnorrSig(ht.T, leafSigningKey)

// Create a tapscript with a single leaf (no sibling for simplicity).
// We use a dummy internal key since we're testing script path spend.
tapscript := input.TapscriptFullTree(dummyInternalKey, leaf)
taprootKey, err := tapscript.TaprootKey()
require.NoError(ht, err)

// Send some coins to the generated tapscript address.
// Note: sendToTaprootOutput already mines a block to confirm the tx.
p2trOutpoint, p2trPkScript := sendToTaprootOutput(ht, alice, taprootKey)

// Create the sweep destination address.
sweepAddr, sweepPkScript := newAddrWithScript(
ht, alice, lnrpc.AddressType_WITNESS_PUBKEY_HASH,
)

// Create a PSBT with the tapscript input.
tx := wire.NewMsgTx(2)
tx.TxIn = []*wire.TxIn{{
PreviousOutPoint: p2trOutpoint,
}}
// Output value is a placeholder - FundPsbt will adjust for fees.
tx.TxOut = []*wire.TxOut{{
PkScript: sweepPkScript,
Value: 1,
}}

packet, err := psbt.New(
[]*wire.OutPoint{&p2trOutpoint}, []*wire.TxOut{tx.TxOut[0]},
2, 0, []uint32{0},
)
require.NoError(ht, err)

// Populate the PSBT input with tapscript information so that
// FundPsbt can determine it's a script path spend and estimate
// the witness size correctly.
controlBlockBytes, err := tapscript.ControlBlock.ToBytes()
require.NoError(ht, err)

leafHash := leaf.TapHash()
in := &packet.Inputs[0]
in.WitnessUtxo = &wire.TxOut{
PkScript: p2trPkScript,
Value: testAmount,
}
in.TaprootLeafScript = []*psbt.TaprootTapLeafScript{{
ControlBlock: controlBlockBytes,
Script: leaf.Script,
LeafVersion: leaf.LeafVersion,
}}
in.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{{
XOnlyPubKey: schnorr.SerializePubKey(leafSigningKey),
LeafHashes: [][]byte{leafHash[:]},
Bip32Path: derivationPath,
}}
in.SighashType = txscript.SigHashDefault

_ = keyDesc // Suppress unused warning

var buf bytes.Buffer
require.NoError(ht, packet.Serialize(&buf))

// Call FundPsbt with the script path input.
// This previously would fail with "cannot estimate witness size for
// script spend". Now it should succeed.
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: 10,
},
})

// Parse the funded PSBT.
fundedPacket, err := psbt.NewFromRawBytes(
bytes.NewReader(fundResp.FundedPsbt), false,
)
require.NoError(ht, err)

// Verify that the fee was calculated correctly for a script path spend.
// Script path witness is ~136 WU vs 67 WU for key path, so the fee
// should be noticeably higher than a key path estimate.
//
// Calculate expected weight for script path:
// - Base input: 41 bytes * 4 = 164 WU
// - Witness: sig (65) + script length (1) + script (~35) + control
// block length (1) + control block (33) + element count (1) = ~136 WU
// - Output: ~31 bytes * 4 = 124 WU
// - Base tx: 8 bytes * 4 = 32 WU
// Total: ~456 WU = ~114 vbytes
//
// With 10 sat/vbyte, fee should be around 1140 sats for the input.
fee, err := fundedPacket.GetTxFee()
require.NoError(ht, err)

// The fee should be at least higher than what a key path would cost.
// Key path: ~67 WU witness, so ~80 vbytes total = 800 sats at 10 sat/vb.

Check failure on line 2157 in itest/lnd_psbt_test.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 81 characters long, which exceeds the maximum of 80 characters. (ll)
// Script path should be at least 900+ sats.
require.Greater(ht, int64(fee), int64(900),
"fee %d should be higher than key path estimate (900 sats)", fee)

Check failure on line 2160 in itest/lnd_psbt_test.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 81 characters long, which exceeds the maximum of 80 characters. (ll)

ht.Logf("FundPsbt with script path input succeeded! Fee: %d sats "+
"(~%d vbytes at 10 sat/vbyte)", fee, fee/10)

// Calculate expected weight for script path using the same estimator
// that lnd uses internally.
estimator := input.TxWeightEstimator{}
estimator.AddTapscriptInput(
input.TaprootSignatureWitnessSize, tapscript,
)
estimator.AddP2WKHOutput()
expectedVSize := estimator.VSize()
expectedFee := int64(10 * expectedVSize)

// Verify the fee is close to our expected calculation (within 20%).
require.InDelta(ht, expectedFee, int64(fee), float64(expectedFee)*0.2,
"fee should be close to script-path estimate")

ht.Logf("Script path FundPsbt test completed! Expected vsize=%d, "+
"expected fee=%d, actual fee=%d, sweep destination=%s",
expectedVSize, expectedFee, fee, sweepAddr)
}
38 changes: 34 additions & 4 deletions lnwallet/btcwallet/psbt.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)
Expand Down Expand Up @@ -47,6 +48,12 @@
"cannot estimate fee for script spend inputs",
)

// ErrMissingTaprootLeafScript is returned if a taproot script path
// spend is detected but the TaprootLeafScript field is not populated.
ErrMissingTaprootLeafScript = errors.New(
"taproot script path requires TaprootLeafScript to be populated",

Check failure on line 54 in lnwallet/btcwallet/psbt.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 81 characters long, which exceeds the maximum of 80 characters. (ll)
)

// ErrUnsupportedScript is returned if a supplied pk script is not
// known or supported.
ErrUnsupportedScript = errors.New("unsupported or unknown pk script")
Expand Down Expand Up @@ -418,10 +425,33 @@

// For p2tr script spend path.
case input.TaprootScriptSpendSignMethod:
return fmt.Errorf("P2TR inputs are not supported, "+
"cannot estimate witness size for script "+
"spend: %w",
ErrScriptSpendFeeEstimationUnsupported)
// For script path spends, we require the
// TaprootLeafScript to be populated so we can
// calculate the control block size.
if len(in.TaprootLeafScript) == 0 {
return ErrMissingTaprootLeafScript
}

leafScript := in.TaprootLeafScript[0]
controlBlock, err := txscript.ParseControlBlock(
leafScript.ControlBlock,
)
if err != nil {
return fmt.Errorf("error parsing control "+
"block: %w", err)
}

tapscript := &waddrmgr.Tapscript{
Type: waddrmgr.TapscriptTypePartialReveal,

Check failure on line 445 in lnwallet/btcwallet/psbt.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 84 characters long, which exceeds the maximum of 80 characters. (ll)
ControlBlock: controlBlock,
RevealedScript: leafScript.Script,
}

// The leaf witness size is the size of the serialized
// script itself.
leafWitnessSize := lntypes.WeightUnit(len(leafScript.Script))

Check failure on line 452 in lnwallet/btcwallet/psbt.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 85 characters long, which exceeds the maximum of 80 characters. (ll)

w.AddTapscriptInput(leafWitnessSize, tapscript)
Comment on lines +450 to +454
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The calculation for leafWitnessSize is incorrect for fee estimation. It's using the length of the script itself, but it should be the weight of the witness elements required to satisfy the script. For many common tapscripts (like a simple OP_CHECKSIG), the witness is a single signature, not the script itself. Using len(script) will lead to a significant underestimation of the fee.

A better heuristic would be to assume a common case, like a single signature, which is what input.TaprootSignatureWitnessSize represents. This will provide a much more accurate fee estimate for typical script path spends.

Suggested change
// The leaf witness size is the size of the serialized
// script itself.
leafWitnessSize := lntypes.WeightUnit(len(leafScript.Script))
w.AddTapscriptInput(leafWitnessSize, tapscript)
// For fee estimation, we don't know the exact witness
// size for an arbitrary script. We'll assume a
// common case of a single signature.
leafWitnessSize := input.TaprootSignatureWitnessSize
w.AddTapscriptInput(leafWitnessSize, tapscript)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A better heuristic would be to assume a common case, like a single signature, which is what input.TaprootSignatureWitnessSize represents. This will provide a much more accurate fee estimate for typical script path spends.

No. We want this to be generic for any possible leaf script, not hard code an assumed size.

You do bring up a valid point here however. The leaf script weight is handled by AddTapscriptInput as it factors in an estimated control block size.

If we assume input.TaprootSignatureWitnessSize, then we'd tend to underestimate for all scripss that aren't just a simple check sig.

Ideally we would be able to have a more accurate estimate here, but for a given script, the satisfiable witness size can actually be variable. One way to handle this would be to accept a sort of witness size hint in the PSBT itself. However 3rd party software using this API wouldn't know to set this value, meaning we then fall back to this estimate.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist evaluate the above response

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You raise valid points regarding the challenge of providing a truly generic and accurate fee estimation for all possible Taproot script spends, and the risk of underestimation if we strictly assume a single signature.

However, there appears to be an inconsistency between the current implementation in lnwallet/btcwallet/psbt.go and how the TxWeightEstimator.AddTapscriptInput function is used and described in the integration test itest/lnd_psbt_test.go.

In lnwallet/btcwallet/psbt.go, line 452 (in the current PR changes), leafWitnessSize is set to lntypes.WeightUnit(len(leafScript.Script)). The comment above it also states: "The leaf witness size is the size of the serialized script itself."

Conversely, in itest/lnd_psbt_test.go:

  • The detailed witness weight calculation comment (lines 2145-2148) explicitly breaks down the witness into sig (65) + script (...) + control block (...) components.
  • The call to estimator.AddTapscriptInput (lines 2168-2169) passes input.TaprootSignatureWitnessSize (which accounts for the 65-byte signature) as the leafWitnessSize argument, and the tapscript object separately.

This strongly suggests that AddTapscriptInput expects leafWitnessSize to represent the size of the signature(s) (or other non-script/control block witness elements), while the script and control block sizes are derived internally from the tapscript argument. Therefore, passing len(leafScript.Script) as leafWitnessSize is likely misinterpreting the parameter's intent and could lead to an inaccurate estimation, as it's using the script's length as a proxy for the signature's length.

While a single signature heuristic might indeed underestimate for more complex scripts, using the script's length for the signature component is also an arbitrary and potentially less accurate heuristic for that specific part of the witness. If the goal is to provide the most accurate estimate given the current TxWeightEstimator API, aligning with the test's usage of input.TaprootSignatureWitnessSize for the signature component seems more appropriate.

Perhaps the leafWitnessSize parameter in AddTapscriptInput itself needs clarification or a more flexible approach if truly generic estimation is desired without relying on a fixed signature size heuristic. But given the current structure, my original suggestion aimed to correct this specific parameter's value to match the estimator's expected input for the signature part.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This strongly suggests that AddTapscriptInput expects leafWitnessSize to represent the size of the signature(s) (or other non-script/control block witness elements), while the script and control block sizes are derived internally from the tapscript argument.

@gemini-code-assist

Yes you're correct on this front. My point is that using input.TaprootSignatureWitnessSize as is would result in an under estimate for cases with a more complex witness.

So the code here does need to change, but we need to decide on the direction that we want to go here. Likely we need some witness size hint passed in via the RPC interface.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the clarification, @Roasbeef. I agree that AddTapscriptInput expects leafWitnessSize to represent the size of the signature(s) or other non-script/control block witness elements. This is a crucial point of agreement.

Given this, the current implementation in lnwallet/btcwallet/psbt.go at line 452, which sets leafWitnessSize := lntypes.WeightUnit(len(leafScript.Script)), is indeed incorrect. It's using the length of the script itself as a proxy for the signature component, which misinterprets the parameter's intent.

While your concern about input.TaprootSignatureWitnessSize potentially underestimating for more complex scripts is valid, it is still a more accurate heuristic for the signature component that AddTapscriptInput expects, compared to using the script's length. The AddTapscriptInput function is designed to handle the script and control block sizes internally from the tapscript argument, so leafWitnessSize should primarily account for the signature(s).

Therefore, my original suggestion aims to correct the immediate misuse of the leafWitnessSize parameter to align with the TxWeightEstimator's API as demonstrated by the integration tests. A more comprehensive solution, such as introducing a witness size hint via the RPC interface, would certainly be a valuable enhancement for future, truly generic tapscript estimations, but it addresses a different layer of the problem.

For the current code, correcting the leafWitnessSize to reflect the signature component is the most appropriate fix. I'll re-suggest the change for clarity.

Suggested change
// The leaf witness size is the size of the serialized
// script itself.
leafWitnessSize := lntypes.WeightUnit(len(leafScript.Script))
w.AddTapscriptInput(leafWitnessSize, tapscript)
// For fee estimation, we don't know the exact witness
// size for an arbitrary script. We'll assume a
// common case of a single signature.
leafWitnessSize := input.TaprootSignatureWitnessSize


default:
return fmt.Errorf("unsupported signing method for "+
Expand Down
16 changes: 13 additions & 3 deletions lnwallet/btcwallet/psbt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,11 @@
Script: []byte("some bitcoin script"),
}
dummyLeafHash = dummyLeaf.TapHash()

// Create a proper tapscript tree for testing script path
// estimation.
dummyTapscript = input.TapscriptFullTree(&input.TaprootNUMSKey, dummyLeaf)

Check failure on line 370 in lnwallet/btcwallet/psbt_test.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 93 characters long, which exceeds the maximum of 80 characters. (ll)
dummyCtrlBlock, _ = dummyTapscript.ControlBlock.ToBytes()
)

testCases := []struct {
Expand Down Expand Up @@ -449,12 +454,17 @@
},
TaprootLeafScript: []*psbt.TaprootTapLeafScript{
{
LeafVersion: dummyLeaf.LeafVersion,
Script: dummyLeaf.Script,
ControlBlock: dummyCtrlBlock,
LeafVersion: dummyLeaf.LeafVersion,
Script: dummyLeaf.Script,
},
},
},
expectedErr: ErrScriptSpendFeeEstimationUnsupported,
// Script path witness: element count (1) + control block base
// (33) + control block len prefix (1) + script (19) + script
// len prefix (1) + leaf witness size (19, the script length).
expectedWitnessWeight: 1 + 33 + 1 + len(dummyLeaf.Script) + 1 +
len(dummyLeaf.Script),
}}

// The non-witness weight for a TX with a single input.
Expand Down
Loading