Skip to content

rpcserver: preserve no-new-change funding semantics through CommitVirtualPsbts to lnd #2209

Description

@darioAnongba

Problem

CommitVirtualPsbts lets a caller hand tapd a fully-specified BTC anchor PSBT (its own exact output topology) and asks tapd to fund it — select inputs, compute fees, and leave the caller's outputs untouched. For custom-anchor / lightweight integrations (e.g. SwapDK, which owns the anchor transaction and needs an exact, deterministic output set), the ability to say "fund this, but do not add any new output" is a hard requirement: an unexpected extra change output changes the txid, shifts output ordering, and can break downstream logic that references outputs by index or a protocol that mandates an exact topology.

The anchor_change_output oneof exposes an add boolean that reads as if add = false expresses exactly this "no new change" policy. It does not. tapd forwards the value faithfully to lnd, but lnd's FundPsbt coin-select path ignores the boolean entirely and will silently append a P2TR change output whenever coin selection produces leftover value above the dust limit. There is no fail-closed "no change" funding mode anywhere in the stack, so a caller that selected an exact topology has no way to enforce it.

Current behaviour

tapd maps the RPC's anchor_change_output oneof 1:1 onto lnd's walletrpc.PsbtCoinSelect change-output oneof in CommitVirtualPsbts:

  • rpcserver/rpcserver.go:3078-3091 — the switch over req.AnchorChangeOutput. The *wrpc.CommitVirtualPsbtsRequest_Add case (rpcserver/rpcserver.go:3084-3087) sets coinSelect.ChangeOutput = &walletrpc.PsbtCoinSelect_Add{Add: change.Add}, i.e. it passes the caller's add bool straight through, and always sets ChangeType: P2TRChangeType (rpcserver/rpcserver.go:3070). The tapd proto documents add only as "Add a new P2TR change output to the PSBT if required" (taprpc/assetwalletrpc/assetwallet.proto:312-325) — there is no documented "no change at all" meaning, and existing_output_index merely folds leftover into an existing output rather than suppressing change.

On the lnd side, the add value is discarded:

  • lnd/lnrpc/walletrpc/walletkit_server.go:1700-1712 — the case *PsbtCoinSelect_Add: matches on the oneof type, never reading t.Add. Both Add: true and Add: false fall into the same branch, which leaves changeIndex = -1 (set at walletkit_server.go:1674) and only picks a change type. The in-code comment states the intent is "no change output should be used if possible or a new one should be created if needed."
  • lnd/lnrpc/walletrpc/walletkit_server.go:2040-2049 and 2091-2100 — in fundPsbtCoinSelect, whenever the computed changeAmt/changeAmount > 0, lnd calls handleChange and adds the output. There is no branch that instead errors out; the only escape is dust (leftover ≤ dust limit).
  • lnd/lnrpc/walletrpc/walletkit.proto:1557-1572 — lnd's own change_output oneof documents add the same way, with no no-change variant.

Net effect: add = false is a silent no-op. lnd behaves identically to add = true and will mutate the caller's exact topology by appending a change output.

For completeness, tapd's internal freighter funding path has the same gap and never even attempts a no-change request: lndservices/wallet_anchor.go:88-119 (called from tapfreighter/wallet.go:1262) only ever sends PsbtCoinSelect_Add{Add: true} (when changeIdx < 0) or ExistingOutputIndex. The only way to get a strictly untouched output set today is skip_funding = true (taprpc/assetwalletrpc/assetwallet.proto:354-358), which bypasses coin selection and fee funding altogether — only viable for pre-funded / zero-fee transactions, not for the general "fund my exact topology" case.

No itest exercises add = false; every current caller passes Add: true or an existing index (itest/psbt_test.go:1225-1226, 2345-2346, 2489-2490, 3838-3839; itest/multisig.go:410-414), which is consistent with the value being meaningless.

Proposed change

This is fundamentally blocked on lnd, so the change is two-part.

  1. lnd (dependency, PsbtCoinSelect / fundPsbtCoinSelect): introduce a real no-change funding mode. Either honor the existing add = false value as "do not add a new change output; return an error if leftover change would exceed the dust limit," or add an explicit variant (e.g. a distinct oneof case / enum) to avoid overloading a boolean whose false value is currently ignored — the latter is cleaner given the field has shipped with the current meaning. The coin-select path at lnd/lnrpc/walletrpc/walletkit_server.go:1700-1712 must thread this decision down to fundPsbtCoinSelect (walletkit_server.go:2040-2049, 2091-2100) so that a non-dust change amount produces an error instead of a silently-appended output.

  2. taproot-assets (CommitVirtualPsbts): once lnd exposes the no-change mode, forward it faithfully from req.AnchorChangeOutput at rpcserver/rpcserver.go:3078-3091 (map tapd's add = false, or a new dedicated variant, onto the new lnd option rather than PsbtCoinSelect_Add). Update the anchor_change_output doc comment in taprpc/assetwalletrpc/assetwallet.proto:312-325 to define the no-change semantics precisely, and add an itest asserting that a CommitVirtualPsbts call with an exact topology and no-change policy either funds without adding an output or fails closed when leftover exceeds dust — never silently appends change.

Until lnd lands the option, tapd should not advertise add = false as meaningful. If it wants to fail closed in the meantime, CommitVirtualPsbts could reject add = false with an explicit "not yet supported" error so callers don't rely on a no-op.

Context

Surfaced while building the tap-sdk advanced custom-anchor transaction builder (lightninglabs/tap-sdk#158), where the caller owns the BTC anchor and needs tapd to fund an exact topology without a surprise change output. Because lnd ignores add = false (lnd/lnrpc/walletrpc/walletkit_server.go:1700-1712), the SDK cannot express a real no-new-change policy through CommitVirtualPsbts and fails closed rather than risk lnd mutating the caller's output set — it declines to use the caller-controlled-topology path until this no-change funding option exists end to end.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status
    🆕 New

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions