Skip to content

assetwalletrpc: make CommitVirtualPsbts idempotent (request ID + status/lease lookup) #2207

Description

@darioAnongba

Problem

AssetWallet.CommitVirtualPsbts has no idempotency or status-lookup surface. Its one persistent side effect is funding the anchor PSBT through lnd, which locks (leases) wallet UTXOs. If the response is lost in transit (client timeout, dropped connection, tapd restart mid-call), the caller cannot tell whether the call succeeded or which lnd leases are now held. There is no request ID to retry against and no way to ask tapd "what happened to this call, and what UTXOs did it lock?".

This matters specifically for lightweight / custom-anchor integrations (e.g. the tap-sdk custom-anchor builder in lightninglabs/tap-sdk#158) that own the BTC anchor transaction and talk to tapd over RPC but do not have direct access to the backing lnd node. For those callers a lost response is unrecoverable in-band:

  • They cannot safely retry. A retry re-runs coin selection (FundPsbt) and locks a different set of UTXOs, producing a different funded PSBT while the first lease set stays locked until it expires. That is double coin selection plus a leaked lease, not an idempotent replay.
  • They cannot discover the leases the first (maybe-successful) call created, because tapd returns only outpoints and intentionally exposes no pass-through to lnd's lease listing.

The result is an out-of-band "outcome-unknown" reconciliation step that the SDK is forced to implement, and which it cannot do correctly without direct lnd access it is designed not to require.

Current behaviour

RPC declared at taprpc/assetwalletrpc/assetwallet.proto:43 and implemented at rpcserver/rpcserver.go:3011.

  • The call is stateless in tapd: it validates inputs, funds the anchor PSBT, computes output commitments and proof suffixes, and returns. No transfer/parcel row is written here — DB persistence happens only later in PublishAndLogTransfer, which calls ChainPorter.RequestShipment (rpcserver/rpcserver.go:3483). So tapd keeps no record that CommitVirtualPsbts ever ran.
  • Funding locks UTXOs via lnd's FundPsbt at rpcserver/rpcserver.go:3111, passing CustomLockId / LockExpirationSeconds straight through (rpcserver/rpcserver.go:3071-3072). Default lock ID and a 10-minute expiration apply when unset.
  • On any error path the leases are released by a deferred cleanup (rpcserver/rpcserver.go:3131-3147), gated by a success flag that is only set immediately before the successful return (rpcserver/rpcserver.go:3055, :3223). This handles server-side failures but does nothing for the case where tapd succeeds and the response is what is lost — the leases stay held and the caller never learns of them.
  • The response carries only the locked outpoints, not lock IDs, with an explicit note that callers are expected to reach into lnd for anything more: "any additional information can be fetched from the lnd wallet directly (we don't want to create pass-through RPCs for all those methods)" (rpcserver/rpcserver.go:3210-3220). CommitVirtualPsbtsResponse.lnd_locked_utxos is taprpc.OutPoint only (taprpc/assetwalletrpc/assetwallet.proto:398).
  • custom_lock_id (taprpc/assetwalletrpc/assetwallet.proto:345) was added (commit 51a9f0b, "support custom lock ID and expiration") to "coordinate UTXO locking across concurrent workflows" and enable "safer, more flexible transaction retargeting". It is the right primitive but stops short of idempotency: even with a deterministic lock ID the caller (a) still needs direct lnd ListLeases access to enumerate the held leases, and (b) cannot recover the funded PSBT / commitments the lost call produced, since those are never persisted.
  • The existing RemoveUTXOLease RPC (rpcserver/rpcserver.go:8601) only calls CoinSelect.ReleaseCoins, i.e. it releases tapd's own coin-select leases, not the lnd-level leases created by FundPsbt under a lock ID. It cannot be used to clean up a leaked lease from a lost CommitVirtualPsbts call.

There is no request_id / idempotency field anywhere in the request or response messages today (confirmed by grep over taprpc/assetwalletrpc/).

Proposed change

Give CommitVirtualPsbts an in-band idempotency and discovery story so a retry is safe and held leases are recoverable without direct lnd access:

  1. Add an optional caller-supplied idempotency key to CommitVirtualPsbtsRequest (e.g. bytes request_id = 11; in taprpc/assetwalletrpc/assetwallet.proto:279). When set, tapd persists the call outcome keyed by that ID: at minimum the effective lock ID and the set of leased outpoints, ideally the full funded anchor PSBT and returned virtual PSBTs so a replay can return byte-identical results.
  2. Make a repeated call with the same request_id idempotent: instead of re-running FundPsbt (which selects/locks fresh UTXOs), return the stored result. If only the lease set was persisted (not the full PSBT), return that plus a clear status so the caller can decide to reuse or release.
  3. Add a status/lookup RPC to the AssetWallet service — e.g. GetCommitVirtualPsbtsStatus keyed by request_id, or a lease lookup keyed by lock ID — returning whether the commit completed and the lnd_locked_utxos (with their lock ID) it holds. This removes the need for the caller to reach into lnd's ListLeases directly and lets a tapd-only integration reconcile an outcome-unknown call.
  4. Optionally extend release handling so a caller can release leases by request_id / lock ID through tapd (today RemoveUTXOLease cannot touch lnd FundPsbt leases; rpcserver/rpcserver.go:8601), closing the leak path for a genuinely-abandoned call.

If a full persisted-PSBT replay is deemed too heavy, the minimum viable fix is (1)+(3) limited to lease bookkeeping: persist {request_id -> lock_id, leased_outpoints, completed} and expose a lookup, which alone converts "outcome unknown, cannot retry" into "look up, then retarget or release deterministically". The exact persistence location (new tapdb table vs. reusing an existing store) needs maintainer confirmation.

Context

Driven by the custom-anchor Taproot Assets transaction builder in lightninglabs/tap-sdk#158, where the caller owns the BTC anchor tx and commits assets via CommitVirtualPsbts before signing externally. Because tapd offers no request ID and no lease/status lookup, the SDK fails closed on a lost response — it cannot safely retry (a retry re-funds and leaks the prior lease) and cannot enumerate the held leases without direct lnd access it is designed not to require, so it surfaces an out-of-band "outcome-unknown" reconciliation rather than resolving the call itself.

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