Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions darepod/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,31 @@ func (s *Server) storeOperatorTerms(terms *types.OperatorTerms) {
s.operatorTerms.Store(terms)
}

// fetchCurrentOperatorPubKey issues a fresh GetInfo round-trip to the
// operator and returns the operator's current long-term public key. The
// daemon-startup OperatorTerms cache is also refreshed as a side effect so
// other readers see the same snapshot. Used to plumb a live operator-key
// lookup into the wallet and VTXO subsystems so refresh emissions build
// the NEW VTXO output's policy template against the operator's join-time
// key — VTXOs commit to their operator key for life, so the new output's
// key is chosen at join time and stays stable on that VTXO forever.
func (s *Server) fetchCurrentOperatorPubKey(ctx context.Context) (
*btcec.PublicKey, error) {

terms, err := s.fetchOperatorTerms(ctx)
if err != nil {
return nil, fmt.Errorf("fetch operator terms: %w", err)
}

// Refresh the cache so unrelated readers (e.g. GetInfo) reflect the
// snapshot the refresh path used. The cache was previously only
// hydrated at daemon startup, which is what made it the wrong source
// of truth in the first place.
s.storeOperatorTerms(terms)

return terms.PubKey, nil
}

// isServerConnected returns the latest mailbox-ingress connectivity signal
// reported by the daemon runtime.
func (s *Server) isServerConnected() bool {
Expand Down Expand Up @@ -3231,6 +3256,7 @@ func (s *Server) initWalletActor(ctx context.Context,
),
wallet.WithClock(s.clk),
wallet.WithEagerRoundJoin(s.cfg.EagerRoundJoin),
wallet.WithFetchOperatorKey(s.fetchCurrentOperatorPubKey),
)
walletKey := actor.NewServiceKey[
wallet.WalletMsg, wallet.WalletResp,
Expand Down Expand Up @@ -3436,6 +3462,7 @@ func (s *Server) initVTXOManager(ctx context.Context,
LedgerSink: fn.Some(ledger.NewSink(s.actorSystem)),
ChainResolver: chainResolver,
RefreshFeeQuoter: s.autoRefreshFeeQuoter(),
FetchOperatorKey: s.fetchCurrentOperatorPubKey,
TerminalVTXOObserver: func(ctx context.Context,
outpoint wire.OutPoint) error {

Expand Down
79 changes: 76 additions & 3 deletions vtxo/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package vtxo

import (
"context"
"errors"
"fmt"
"log/slog"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/wire"
Expand Down Expand Up @@ -89,6 +91,22 @@ type VTXOActorConfig struct {
// emits RefreshVTXORequest with OperatorFee=0, which is fine:
// the seal-time quote is still the source of truth.
RefreshFeeQuoter RefreshFeeQuoter

// FetchOperatorKey, when set, returns the operator's current
// long-term public key by issuing a fresh GetInfo round-trip to
// the operator at the moment of an auto-refresh emission. The
// fetched key is used to build the NEW VTXO output's policy
// template; the input VTXO's stored operator key is intentionally
// not reused for the new output because VTXOs commit to their
// operator key for their entire lifetime, and the new output is a
// fresh VTXO whose operator key is chosen at join time.
//
// A nil callback causes refreshOutputTemplate to fall back to the
// descriptor's stored bytes (harness paths and pre-fix behavior).
// A non-nil callback that errors propagates the error so the
// refresh fails loudly rather than silently emitting against a
// stale key; the next expiry tick will retry.
FetchOperatorKey func(context.Context) (*btcec.PublicKey, error)
}

// VTXOActor manages the lifecycle of a single VTXO. It processes events using
Expand Down Expand Up @@ -127,6 +145,51 @@ func (a *VTXOActor) logger(ctx context.Context) btclog.Logger {
return a.cfg.Log.UnwrapOr(build.LoggerFromContext(ctx))
}

// refreshOutputTemplate returns the policy template the auto-refresh emission
// should attach to the relayed RefreshVTXORequest for the NEW VTXO output.
//
// The new output is a freshly-minted VTXO whose operator key is chosen at
// join time — VTXOs commit to their operator key for life, so the input
// VTXO's stored key must not leak into the new output. When the
// FetchOperatorKey seam is wired, the actor issues a fresh GetInfo and uses
// the returned key to rebuild the standard template. For non-standard shapes
// (vHTLC etc.) the rebuild surface is unavailable and the actor falls back
// to the descriptor's stored bytes, accepting that a key rotation across
// those VTXOs will still be rejected by the rounds validator. When the seam
// is unset the actor also falls back, which keeps harness paths working
// unchanged.
func (a *VTXOActor) refreshOutputTemplate(ctx context.Context,
vtxo *Descriptor) ([]byte, error) {

if a.cfg.FetchOperatorKey == nil {
return vtxo.EffectivePolicyTemplate()
}

currentKey, err := a.cfg.FetchOperatorKey(ctx)
if err != nil {
return nil, fmt.Errorf("fetch current operator key: %w", err)
}
if currentKey == nil {
return nil, fmt.Errorf("fetch current operator key: nil key " +
"returned")
}

rebuilt, err := vtxo.RefreshOutputTemplate(currentKey)
if err != nil {
// Non-standard policy: keep the stored bytes. The server may
// still accept the request if the operator key happens to
// match; if not, the rounds validator will reject it and the
// caller will see the same error surface as before the fix.
if errors.Is(err, ErrRefreshOperatorKeyUnsupported) {
return vtxo.EffectivePolicyTemplate()
}

return nil, err
}

return rebuilt, nil
}

// emitExitCost is the VTXO-actor entry point for emitting an
// ExitCostMsg to the client ledger on unilateral exit. It is a
// no-op today: the ledger handler requires both AmountSat > 0
Expand Down Expand Up @@ -347,11 +410,21 @@ func (a *VTXOActor) processOutbox(ctx context.Context,
// the round-specific message here since the VTXO
// actor has the descriptor data needed.
vtxo := a.cfg.VTXO
policyTemplate, err := vtxo.EffectivePolicyTemplate()
policyTemplate, err := a.refreshOutputTemplate(
ctx, vtxo,
)
if err != nil {
a.logger(ctx).ErrorS(
// WarnS, not ErrorS: this can fail because
// the FetchOperatorKey callback returned an
// error (operator unreachable, fresh GetInfo
// timed out) — an external trigger, not an
// internal bug. The next expiry tick will
// retry; skipping this emission is the right
// local behavior.
a.logger(ctx).WarnS(
ctx,
"Failed to encode refresh policy",
"Failed to build refresh output "+
"template",
err,
slog.String(
"outpoint",
Expand Down
12 changes: 12 additions & 0 deletions vtxo/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"
"time"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/wire"
Expand Down Expand Up @@ -85,6 +86,16 @@ type ManagerConfig struct {
// fills in the residual via the JoinRoundQuote.
RefreshFeeQuoter RefreshFeeQuoter

// FetchOperatorKey is propagated to each spawned VTXOActor so
// the auto-refresh emission can fetch the operator's current
// long-term key at join time and rebuild the NEW VTXO output's
// policy template against it. A fresh fetch is used — rather
// than a daemon-startup cache — because VTXOs commit to their
// operator key for life and the new output's key is chosen at
// join time. Nil leaves the spawned actors falling back to the
// descriptor's stored bytes (the pre-fix behavior).
FetchOperatorKey func(context.Context) (*btcec.PublicKey, error)

// TerminalVTXOObserver receives the outpoint of VTXOs that leave the
// manager's active set so daemon-local observers can clean up related
// actor-owned work.
Expand Down Expand Up @@ -560,6 +571,7 @@ func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) (
Manager: m.managerRef,
LedgerSink: m.cfg.LedgerSink,
RefreshFeeQuoter: m.cfg.RefreshFeeQuoter,
FetchOperatorKey: m.cfg.FetchOperatorKey,
}

vtxoActor := NewVTXOActor(ctx, actorCfg)
Expand Down
61 changes: 61 additions & 0 deletions vtxo/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,24 @@ package vtxo

import (
"bytes"
"errors"
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightninglabs/darepo-client/lib/arkscript"
)

// ErrRefreshOperatorKeyUnsupported is returned by RefreshOutputTemplate when
// the descriptor's stored policy is not the standard Ark VTXO shape. The
// operator key sits in a fixed position in standard policies but not in
// custom ones (e.g. vHTLC), so a structural rewrite there could shift the
// wrong field. Callers must either keep the input shape (and fail at the
// rounds validator if the rotation still applies) or surface this error to
// the user with rotation-specific UX.
var ErrRefreshOperatorKeyUnsupported = errors.New("refresh-time operator key " +
"rewrite only supported for standard VTXO policies")

// EffectivePolicyTemplate returns the semantic policy for the VTXO.
func (d *Descriptor) EffectivePolicyTemplate() ([]byte, error) {
if d == nil {
Expand Down Expand Up @@ -56,6 +68,55 @@ func (d *Descriptor) DecodeStandardPolicyTemplate() (
return arkscript.DecodeStandardVTXOParams(template)
}

// RefreshOutputTemplate returns the policy template that should be used for
// the NEW VTXO output that a refresh round mints from this descriptor.
//
// The descriptor's stored PolicyTemplate field carries the operator key the
// VTXO was originally created under (call that K1). When the operator has
// since rotated to a different long-term key (K2), reusing the stored bytes
// verbatim ships K1 inside the JoinRoundRequest's new VTXO template — the
// server's rounds validator then rejects the request with
// ErrOperatorKeyMismatch.
//
// The fix path: rebuild the new output's template with the caller-supplied
// current operator key while preserving the owner key and exit delay that
// the existing descriptor commits to. This intentionally only touches the
// new output side; spend-time material for the old VTXO (forfeit witnesses,
// unilateral exit script) still has to use K1 because that is what the
// original output's taproot tree committed to.
//
// Only the standard Ark VTXO shape is supported here. Custom shapes (vHTLC,
// etc.) return ErrRefreshOperatorKeyUnsupported so callers can surface the
// limitation explicitly rather than silently producing a misshaped template.
//
// A nil currentOperatorKey returns an error so callers that have not wired
// the operator-terms cache yet fail loudly instead of producing a template
// with a zero key.
func (d *Descriptor) RefreshOutputTemplate(
currentOperatorKey *btcec.PublicKey) ([]byte, error) {

if d == nil {
return nil, fmt.Errorf("descriptor must be provided")
}

if currentOperatorKey == nil {
return nil, fmt.Errorf("current operator key must be provided")
}

// Decode the stored template once so we can lift the owner key and
// exit delay back out — those still belong to the holder of this
// VTXO and survive the operator rotation untouched.
params, err := d.DecodeStandardPolicyTemplate()
if err != nil {
return nil, fmt.Errorf("%w: %w",
ErrRefreshOperatorKeyUnsupported, err)
}

return arkscript.EncodeStandardVTXOTemplate(
params.OwnerKey, currentOperatorKey, params.ExitDelay,
)
}

// StandardTapScript derives the standard tapscript for descriptors that use
// the default Ark policy shape.
func (d *Descriptor) StandardTapScript() (*waddrmgr.Tapscript, error) {
Expand Down
Loading
Loading