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.
-
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.
-
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.
Problem
CommitVirtualPsbtslets 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_outputoneof exposes anaddboolean that reads as ifadd = falseexpresses exactly this "no new change" policy. It does not. tapd forwards the value faithfully to lnd, but lnd'sFundPsbtcoin-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_outputoneof 1:1 onto lnd'swalletrpc.PsbtCoinSelectchange-output oneof inCommitVirtualPsbts:rpcserver/rpcserver.go:3078-3091— theswitchoverreq.AnchorChangeOutput. The*wrpc.CommitVirtualPsbtsRequest_Addcase (rpcserver/rpcserver.go:3084-3087) setscoinSelect.ChangeOutput = &walletrpc.PsbtCoinSelect_Add{Add: change.Add}, i.e. it passes the caller'saddbool straight through, and always setsChangeType: P2TRChangeType(rpcserver/rpcserver.go:3070). The tapd proto documentsaddonly 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, andexisting_output_indexmerely folds leftover into an existing output rather than suppressing change.On the lnd side, the
addvalue is discarded:lnd/lnrpc/walletrpc/walletkit_server.go:1700-1712— thecase *PsbtCoinSelect_Add:matches on the oneof type, never readingt.Add. BothAdd: trueandAdd: falsefall into the same branch, which leaveschangeIndex = -1(set atwalletkit_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-2049and2091-2100— infundPsbtCoinSelect, whenever the computedchangeAmt/changeAmount > 0, lnd callshandleChangeand 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 ownchange_outputoneof documentsaddthe same way, with no no-change variant.Net effect:
add = falseis a silent no-op. lnd behaves identically toadd = trueand 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 fromtapfreighter/wallet.go:1262) only ever sendsPsbtCoinSelect_Add{Add: true}(whenchangeIdx < 0) orExistingOutputIndex. The only way to get a strictly untouched output set today isskip_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 passesAdd: trueor 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.
lnd (dependency,
PsbtCoinSelect/fundPsbtCoinSelect): introduce a real no-change funding mode. Either honor the existingadd = falsevalue 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 atlnd/lnrpc/walletrpc/walletkit_server.go:1700-1712must thread this decision down tofundPsbtCoinSelect(walletkit_server.go:2040-2049,2091-2100) so that a non-dust change amount produces an error instead of a silently-appended output.taproot-assets (
CommitVirtualPsbts): once lnd exposes the no-change mode, forward it faithfully fromreq.AnchorChangeOutputatrpcserver/rpcserver.go:3078-3091(map tapd'sadd = false, or a new dedicated variant, onto the new lnd option rather thanPsbtCoinSelect_Add). Update theanchor_change_outputdoc comment intaprpc/assetwalletrpc/assetwallet.proto:312-325to define the no-change semantics precisely, and add an itest asserting that aCommitVirtualPsbtscall 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 = falseas meaningful. If it wants to fail closed in the meantime,CommitVirtualPsbtscould rejectadd = falsewith 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 throughCommitVirtualPsbtsand 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.