Problem
WalletKit.FundPsbt has no way to say "fund this transaction from wallet inputs, but do not add a change output." A no-change intent is indistinguishable from the default "add change if there is a non-dust remainder" behaviour, so a caller who supplied an exact output topology can silently have an extra change output appended (and its output indices shifted).
This matters for lightweight / custom-anchor integrations that own the BTC anchor transaction and commit application data (e.g. Taproot Asset commitments) to a precise output layout. For those callers the output set is the contract: appending a change output, or growing an existing output, mutates the topology the caller carefully constructed and invalidates the commitment. Today the only recourse is to either post-process lnd's output (fragile, and the change key has already been derived and persisted) or avoid FundPsbt entirely for exact-topology flows.
Current behaviour
FundPsbt dispatches on the template type at lnrpc/walletrpc/walletkit_server.go:1632. None of the three paths can suppress change:
-
psbt template (lnrpc/walletrpc/walletkit_server.go:1643) and raw template (lnrpc/walletrpc/walletkit_server.go:1784) both call fundPsbtInternalWallet (lnrpc/walletrpc/walletkit_server.go:1798), which delegates to BtcWallet.FundPsbt (lnwallet/btcwallet/psbt.go:78, call site lnwallet/btcwallet/psbt.go:144). Its own doc comment states the contract: "If there is change left, a change output from the internal wallet is added ... Otherwise no additional output is created and the index -1 is returned" (lnwallet/btcwallet/psbt.go:55, mirrored on the WalletController interface at lnwallet/interface.go:461). The decision is made unconditionally inside btcwallet's wallet.FundPsbt, which always installs a change source and lets txauthor.NewUnsignedTransaction add change whenever the remainder exceeds dust (btcsuite/btcwallet wallet/psbt.go:183-209). There is no option, at either the lnd or btcwallet layer, to omit the change source.
-
coin_select template (lnrpc/walletrpc/walletkit_server.go:1651) requires a change_output instruction via the oneof handled at lnrpc/walletrpc/walletkit_server.go:1677-1716. The oneof offers only existing_output_index and add; anything else falls through to the default error "unknown change output type" (lnrpc/walletrpc/walletkit_server.go:1714). The add case (lnrpc/walletrpc/walletkit_server.go:1700) leaves changeIndex = -1 and selects a change type, so when the packet is over-funded the over-funded branch (lnrpc/walletrpc/walletkit_server.go:2005-2056) computes changeAmt and, if it is above dust, calls handleChange (lnrpc/walletrpc/walletkit_server.go:2206) — which appends a brand-new output to packet.UnsignedTx.TxOut (lnrpc/walletrpc/walletkit_server.go:2260-2267). The same append happens on the coin-selection path at lnrpc/walletrpc/walletkit_server.go:2091-2100.
The only thing that ever suppresses a change output is the dust threshold in chanfunding.CalculateChangeAmount (lnwallet/chanfunding/coin_select.go:278): change below the dust limit is dropped to fees. There is no caller-controllable switch — a remainder above dust always becomes change. In other words, to get "no change" today a caller must hit inputSum == outputSum + fee within one dust limit, which is not achievable in practice because the fee depends on the final tx weight.
Proposed change
Add a genuine, first-class "no change" funding instruction and thread it down so the caller-exact topology is preserved:
-
Proto (lnrpc/walletrpc/walletkit.proto): extend the PsbtCoinSelect.change_output oneof (lnrpc/walletrpc/walletkit.proto:1557) with a third variant, e.g. bool use_existing_output_only = 4; / bool no_change = 4;, and add an equivalent top-level flag on FundPsbtRequest (near change_type at lnrpc/walletrpc/walletkit.proto:1488) so the psbt and raw internal-wallet templates can express it too. Document that when set, FundPsbt will never append or grow a change output and change_output_index (lnrpc/walletrpc/walletkit.proto:1515) is always -1.
-
Semantics (fail-closed): with no-change set, if the selected/supplied inputs exceed outputs + fee by more than some tolerance, return an error rather than silently sending the excess to fees or adding change. This forces the caller to supply an exact input set (which a custom-anchor caller controls) or to add their own change output explicitly. Optionally allow a separate explicit opt-in to "send excess to fees" for callers that truly want it, but the default no-change behaviour should be to preserve the exact output set and error on meaningful over-funding.
-
Wiring: plumb the flag through fundPsbtInternalWallet / fundPsbtCoinSelect, and add a corresponding option to the WalletController.FundPsbt interface (lnwallet/interface.go:478) and its BtcWallet implementation (lnwallet/btcwallet/psbt.go:78). At the btcwallet layer this likely means passing a nil / no-op change source to wallet.FundPsbt (a new TxCreateOption, alongside the existing WithCustomChangeScope used at lnwallet/btcwallet/psbt.go:135) so txauthor never adds change — this part needs confirmation against the btcwallet wallet.FundPsbt API (btcsuite/btcwallet wallet/psbt.go:47) and may require an upstream btcwallet change. For the coin_select path the change can be handled entirely in lnd by skipping the handleChange calls and treating a non-zero remainder as an error.
-
Guard the internal-wallet paths against the case where inputs are present but the flag is set with an over-funded packet, mirroring the existing over-funded handling in fundPsbtCoinSelect.
Context
Surfaced while building the custom-anchor Taproot Assets transaction builder in lightninglabs/tap-sdk#158, where the SDK owns the BTC anchor, commits assets via tapd against a caller-exact output topology, signs externally, then publishes. The SDK deliberately fails closed here: because FundPsbt cannot promise it will leave the output set untouched, the SDK cannot delegate change-sensitive anchor funding to lnd and instead requires exact, pre-selected inputs — losing lnd's coin selection for this flow. A first-class no-change instruction would let tapd fund a custom anchor via lnd while guaranteeing the commitment layout is preserved.
Problem
WalletKit.FundPsbthas no way to say "fund this transaction from wallet inputs, but do not add a change output." A no-change intent is indistinguishable from the default "add change if there is a non-dust remainder" behaviour, so a caller who supplied an exact output topology can silently have an extra change output appended (and its output indices shifted).This matters for lightweight / custom-anchor integrations that own the BTC anchor transaction and commit application data (e.g. Taproot Asset commitments) to a precise output layout. For those callers the output set is the contract: appending a change output, or growing an existing output, mutates the topology the caller carefully constructed and invalidates the commitment. Today the only recourse is to either post-process lnd's output (fragile, and the change key has already been derived and persisted) or avoid
FundPsbtentirely for exact-topology flows.Current behaviour
FundPsbtdispatches on the template type atlnrpc/walletrpc/walletkit_server.go:1632. None of the three paths can suppress change:psbttemplate (lnrpc/walletrpc/walletkit_server.go:1643) andrawtemplate (lnrpc/walletrpc/walletkit_server.go:1784) both callfundPsbtInternalWallet(lnrpc/walletrpc/walletkit_server.go:1798), which delegates toBtcWallet.FundPsbt(lnwallet/btcwallet/psbt.go:78, call sitelnwallet/btcwallet/psbt.go:144). Its own doc comment states the contract: "If there is change left, a change output from the internal wallet is added ... Otherwise no additional output is created and the index -1 is returned" (lnwallet/btcwallet/psbt.go:55, mirrored on theWalletControllerinterface atlnwallet/interface.go:461). The decision is made unconditionally inside btcwallet'swallet.FundPsbt, which always installs a change source and letstxauthor.NewUnsignedTransactionadd change whenever the remainder exceeds dust (btcsuite/btcwallet wallet/psbt.go:183-209). There is no option, at either the lnd or btcwallet layer, to omit the change source.coin_selecttemplate (lnrpc/walletrpc/walletkit_server.go:1651) requires achange_outputinstruction via the oneof handled atlnrpc/walletrpc/walletkit_server.go:1677-1716. The oneof offers onlyexisting_output_indexandadd; anything else falls through to thedefaulterror"unknown change output type"(lnrpc/walletrpc/walletkit_server.go:1714). Theaddcase (lnrpc/walletrpc/walletkit_server.go:1700) leaveschangeIndex = -1and selects a change type, so when the packet is over-funded the over-funded branch (lnrpc/walletrpc/walletkit_server.go:2005-2056) computeschangeAmtand, if it is above dust, callshandleChange(lnrpc/walletrpc/walletkit_server.go:2206) — which appends a brand-new output topacket.UnsignedTx.TxOut(lnrpc/walletrpc/walletkit_server.go:2260-2267). The same append happens on the coin-selection path atlnrpc/walletrpc/walletkit_server.go:2091-2100.The only thing that ever suppresses a change output is the dust threshold in
chanfunding.CalculateChangeAmount(lnwallet/chanfunding/coin_select.go:278): change below the dust limit is dropped to fees. There is no caller-controllable switch — a remainder above dust always becomes change. In other words, to get "no change" today a caller must hitinputSum == outputSum + feewithin one dust limit, which is not achievable in practice because the fee depends on the final tx weight.Proposed change
Add a genuine, first-class "no change" funding instruction and thread it down so the caller-exact topology is preserved:
Proto (
lnrpc/walletrpc/walletkit.proto): extend thePsbtCoinSelect.change_outputoneof (lnrpc/walletrpc/walletkit.proto:1557) with a third variant, e.g.bool use_existing_output_only = 4;/bool no_change = 4;, and add an equivalent top-level flag onFundPsbtRequest(nearchange_typeatlnrpc/walletrpc/walletkit.proto:1488) so thepsbtandrawinternal-wallet templates can express it too. Document that when set,FundPsbtwill never append or grow a change output andchange_output_index(lnrpc/walletrpc/walletkit.proto:1515) is always-1.Semantics (fail-closed): with no-change set, if the selected/supplied inputs exceed
outputs + feeby more than some tolerance, return an error rather than silently sending the excess to fees or adding change. This forces the caller to supply an exact input set (which a custom-anchor caller controls) or to add their own change output explicitly. Optionally allow a separate explicit opt-in to "send excess to fees" for callers that truly want it, but the default no-change behaviour should be to preserve the exact output set and error on meaningful over-funding.Wiring: plumb the flag through
fundPsbtInternalWallet/fundPsbtCoinSelect, and add a corresponding option to theWalletController.FundPsbtinterface (lnwallet/interface.go:478) and itsBtcWalletimplementation (lnwallet/btcwallet/psbt.go:78). At the btcwallet layer this likely means passing a nil / no-op change source towallet.FundPsbt(a newTxCreateOption, alongside the existingWithCustomChangeScopeused atlnwallet/btcwallet/psbt.go:135) sotxauthornever adds change — this part needs confirmation against the btcwalletwallet.FundPsbtAPI (btcsuite/btcwallet wallet/psbt.go:47) and may require an upstream btcwallet change. For thecoin_selectpath the change can be handled entirely in lnd by skipping thehandleChangecalls and treating a non-zero remainder as an error.Guard the internal-wallet paths against the case where inputs are present but the flag is set with an over-funded packet, mirroring the existing over-funded handling in
fundPsbtCoinSelect.Context
Surfaced while building the custom-anchor Taproot Assets transaction builder in lightninglabs/tap-sdk#158, where the SDK owns the BTC anchor, commits assets via tapd against a caller-exact output topology, signs externally, then publishes. The SDK deliberately fails closed here: because
FundPsbtcannot promise it will leave the output set untouched, the SDK cannot delegate change-sensitive anchor funding to lnd and instead requires exact, pre-selected inputs — losing lnd's coin selection for this flow. A first-class no-change instruction would let tapd fund a custom anchor via lnd while guaranteeing the commitment layout is preserved.