Skip to content

walletrpc: support an explicit no-change instruction in FundPsbt #10967

Description

@darioAnongba

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:

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

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions