Skip to content
Open
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
20 changes: 16 additions & 4 deletions arkrpc/ark.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions arkrpc/ark.proto
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ message GetInfoResponse {
// The number of blocks before batch expiry in which a pure refresh
// qualifies for a complete fee waiver. Zero disables the waiver.
uint32 free_refresh_window_blocks = 23;

// The confirmations required before clients may use VTXOs created by a
// round. This is independent of min_confirmations, which protects the
// on-chain boarding inputs consumed by a new round.
uint32 vtxo_confirmations = 24;
}

// EstimateFeeRequest asks the server to estimate the fee for a
Expand Down
19 changes: 19 additions & 0 deletions lib/types/boarding.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ type OperatorTerms struct {
// MinConfirmations is the minimum confs required on boarding inputs.
MinConfirmations uint32

// VTXOConfirmations is the confirmation depth at which VTXOs created by
// a round become available for off-chain spending.
VTXOConfirmations uint32

// MaxOORLineageVBytes is the operator-published cap on the
// cumulative on-chain virtual bytes a recipient must publish to
// claim a VTXO produced by an OOR submit unilaterally. Clients
Expand All @@ -104,6 +108,21 @@ func (t *OperatorTerms) MinVTXOAmountFloor() btcutil.Amount {
return t.MinVTXOAmount
}

// VTXOTargetConfirmations returns the depth at which round VTXOs become
// available. A zero split field preserves the legacy policy that reused the
// boarding-input minimum for commitment outputs.
func (t *OperatorTerms) VTXOTargetConfirmations() uint32 {
if t == nil {
return 0
}

if t.VTXOConfirmations == 0 {
return t.MinConfirmations
}

return t.VTXOConfirmations
}

// JoinRoundRequest represents a participant's request to join a round.
type JoinRoundRequest struct {
// Identifier is the participant's public key identifier associated with
Expand Down
3 changes: 3 additions & 0 deletions oor/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/oor.<Sym
- Terminal rows (completed and failed) are retained in
`oor_session_registry` for status/diagnostics; reaping only removes the
in-memory child, never the row.
- Pending submit retries persist their delay and typed rejection reason in the
outgoing snapshot. `GetOORSession` exposes the reason while status remains
pending, so callers can distinguish a recoverable chain pause from failure.
- Outgoing finalize ordering: input-spend completion runs inline with no OOR
writer transaction held, because its write commits in the VTXO manager's
own transaction; awaiting that second writer under a held OOR writer lock
Expand Down
3 changes: 3 additions & 0 deletions oor/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/oor.<Sym
- Terminal rows (completed and failed) are retained in
`oor_session_registry` for status/diagnostics; reaping only removes the
in-memory child, never the row.
- Pending submit retries persist their delay and typed rejection reason in the
outgoing snapshot. `GetOORSession` exposes the reason while status remains
pending, so callers can distinguish a recoverable chain pause from failure.
- Outgoing finalize ordering: input-spend completion runs inline with no OOR
writer transaction held, because its write commits in the VTXO manager's
own transaction; awaiting that second writer under a held OOR writer lock
Expand Down
4 changes: 4 additions & 0 deletions oor/outgoing_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ func NewOutgoingSnapshot(sessionID SessionID,
snap.DispatchRequestData = append(
[]byte(nil), s.DispatchRequestData...,
)
snap.RetryAfter = s.RetryAfter
snap.FailReason = s.RetryReason
snap.FirstRejectUnixNanos = s.FirstRejectUnixNanos

if err := assignPSBTArtifacts(
Expand Down Expand Up @@ -341,6 +343,8 @@ func OutgoingStateFromSnapshot(snapshot *OutgoingSnapshot) (State, error) {
DispatchRequestData: append(
[]byte(nil), snapshot.DispatchRequestData...,
),
RetryAfter: snapshot.RetryAfter,
RetryReason: snapshot.FailReason,
FirstRejectUnixNanos: snapshot.FirstRejectUnixNanos,
}, nil

Expand Down
6 changes: 6 additions & 0 deletions oor/outgoing_snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ func TestOutgoingRegistryRecordSuppliesNormalizedReplayProof(t *testing.T) {
require.NoError(t, err)
awaiting, ok := submit.NextState.(*AwaitingSubmitAccepted)
require.True(t, ok)
awaiting.RetryAfter = 15 * time.Second
awaiting.RetryReason = "input not spendable"

sessionID, err := sessionIDFromArk(awaiting.ArkPSBT)
require.NoError(t, err)
Expand All @@ -159,6 +161,10 @@ func TestOutgoingRegistryRecordSuppliesNormalizedReplayProof(t *testing.T) {
proofRecord, err := outgoingRegistryRecord(sessionID, awaiting)
require.NoError(t, err)
require.NotEmpty(t, proofRecord.DispatchRequestData)
persisted, err := decodeOutgoingSnapshot(proofRecord.SnapshotData)
require.NoError(t, err)
require.Equal(t, 15*time.Second, persisted.RetryAfter)
require.Equal(t, "input not spendable", persisted.FailReason)

recipients, err := OutgoingReplayRecipients(
proofRecord.DispatchRequestData,
Expand Down
8 changes: 8 additions & 0 deletions oor/states.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package oor

import (
"time"

"github.com/btcsuite/btcd/psbt/v2"
"github.com/lightninglabs/wavelength/baselib/protofsm"
oortx "github.com/lightninglabs/wavelength/lib/tx/oor"
Expand Down Expand Up @@ -107,6 +109,12 @@ type AwaitingSubmitAccepted struct {
// DispatchRequestData is the normalized caller-recipient proof.
DispatchRequestData []byte

// RetryAfter and RetryReason describe the latest pending submit retry.
// They are persisted so operation status can expose a transient typed
// rejection without turning it into a terminal failure.
RetryAfter time.Duration
RetryReason string

// FirstRejectUnixNanos is the Unix-nanosecond timestamp of the first
// transient submit rejection observed while awaiting submit acceptance,
// used to bound the cumulative retry window. Zero means no transient
Expand Down
4 changes: 4 additions & 0 deletions oor/submit_retry_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ func TestHandleSubmitOutboxErrorReschedulesWithinBudget(t *testing.T) {
next, ok := transition.NextState.(*AwaitingSubmitAccepted)
require.True(t, ok)
require.Equal(t, start.UnixNano(), next.FirstRejectUnixNanos)
require.Equal(t, 15*time.Second, next.RetryAfter)
require.Equal(t, "input not spendable", next.RetryReason)

// The source state must not be mutated in place.
require.Zero(t, current.FirstRejectUnixNanos)
Expand Down Expand Up @@ -115,6 +117,8 @@ func TestHandleSubmitOutboxErrorReschedulesWithinBudget(t *testing.T) {
next2, ok := transition.NextState.(*AwaitingSubmitAccepted)
require.True(t, ok)
require.Equal(t, start.UnixNano(), next2.FirstRejectUnixNanos)
require.Equal(t, 15*time.Second, next2.RetryAfter)
require.Equal(t, "input not spendable", next2.RetryReason)
require.Len(t, transition.NewEvents.UnwrapOr(EmittedEvent{}).Outbox, 1)
}

Expand Down
2 changes: 2 additions & 0 deletions oor/transitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,8 @@ func handleSubmitOutboxError(env *Environment, current *AwaitingSubmitAccepted,
if after == 0 {
after = defaultRetryDelay
}
next.RetryAfter = after
next.RetryReason = evt.ErrorReason

return &StateTransition{
NextState: &next,
Expand Down
2 changes: 1 addition & 1 deletion round/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,7 @@ func (a *RoundClientActor) registerCommitmentConfirmation(ctx context.Context,
CallerID: callerID,
Txid: &txid,
PkScript: pkScript,
TargetConfs: a.cfg.OperatorTerms.MinConfirmations,
TargetConfs: a.cfg.OperatorTerms.VTXOTargetConfirmations(),
HeightHint: heightHint,
NotifyActor: fn.Some(mappedRef),
}
Expand Down
27 changes: 16 additions & 11 deletions round/transitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3119,11 +3119,12 @@ func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox(
ForfeitTxs: forfeitTxs,
},
&RegisterConfirmationRequest{
CallerID: callerID,
Txid: &txid,
PkScript: pkScript,
TargetConfs: env.OperatorTerms.MinConfirmations,
HeightHint: env.StartHeight,
CallerID: callerID,
Txid: &txid,
PkScript: pkScript,
TargetConfs: env.OperatorTerms.
VTXOTargetConfirmations(),
HeightHint: env.StartHeight,
},
}

Expand Down Expand Up @@ -3587,18 +3588,22 @@ func (s *PartialSigsSentState) processEvent(ctx context.Context,
slog.Int("pkscript_len", len(pkScript)),
slog.Int(
"target_confs",
int(env.OperatorTerms.MinConfirmations),
int(
env.OperatorTerms.
VTXOTargetConfirmations(),
),
),
)

outboxMsgs := []ClientOutMsg{
forfeitSigReq,
&RegisterConfirmationRequest{
CallerID: callerID,
Txid: &txid,
PkScript: pkScript,
TargetConfs: env.OperatorTerms.MinConfirmations,
HeightHint: env.StartHeight,
CallerID: callerID,
Txid: &txid,
PkScript: pkScript,
TargetConfs: env.OperatorTerms.
VTXOTargetConfirmations(),
HeightHint: env.StartHeight,
},
}

Expand Down
5 changes: 5 additions & 0 deletions sdk/ark/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ type ServerInfo struct {
// inputs.
MinConfirmations uint32

// VTXOConfirmations is the confirmation depth at which VTXOs created by
// a round become available for off-chain spending.
VTXOConfirmations uint32

// MaxUserBalance is the maximum total balance in satoshis a single
// user should hold in the system. A value of zero means no cap.
MaxUserBalance uint64
Expand Down Expand Up @@ -470,6 +474,7 @@ func (c *Client) GetInfo(ctx context.Context) (*Info, error) {
MinOperatorFee: serverInfo.MinOperatorFee,
FreeRefreshWindowBlocks: freeRefreshWindow,
MinConfirmations: serverInfo.MinConfirmations,
VTXOConfirmations: serverInfo.VtxoConfirmations,
MaxUserBalance: serverInfo.MaxUserBalance,
}
}
Expand Down
3 changes: 3 additions & 0 deletions sdk/ark/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func newFakeDaemonService() *fakeDaemonService {
MinOperatorFee: 20,
FreeRefreshWindowBlocks: 120,
MinConfirmations: 2,
VtxoConfirmations: 1,
},
},
listVtxosResp: &waverpc.ListVTXOsResponse{
Expand Down Expand Up @@ -685,6 +686,8 @@ func TestDialRemoteGetInfo(t *testing.T) {
require.Equal(t, uint32(120),
info.ServerInfo.FreeRefreshWindowBlocks,
)
require.Equal(t, uint32(2), info.ServerInfo.MinConfirmations)
require.Equal(t, uint32(1), info.ServerInfo.VTXOConfirmations)
}

// TestDialRemoteCoversFacadeMethods verifies the thin SDK wrappers beyond
Expand Down
9 changes: 9 additions & 0 deletions waved/operator_negotiation.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ func operatorTermsFromResponse(resp *arkrpc.GetInfoResponse) (
minVTXOAmount = resp.DustLimit
}

vtxoConfirmations := resp.VtxoConfirmations
if vtxoConfirmations == 0 {
// Older operators used MinConfirmations for both boarding
// inputs and commitment outputs. Preserve that behavior until
// the server explicitly advertises the split policy.
vtxoConfirmations = resp.MinConfirmations
}

// The forfeit penalty key, sweep key and sweep delay are no longer
// global operator terms; they are delivered per round in the batch
// info, so GetInfo no longer carries them.
Expand All @@ -69,6 +77,7 @@ func operatorTermsFromResponse(resp *arkrpc.GetInfoResponse) (
MinOperatorFee: btcutil.Amount(resp.MinOperatorFee),
FreeRefreshWindowBlocks: resp.FreeRefreshWindowBlocks,
MinConfirmations: resp.MinConfirmations,
VTXOConfirmations: vtxoConfirmations,
MaxOORLineageVBytes: resp.MaxOorLineageVbytes,
MaxUserBalance: btcutil.Amount(resp.MaxUserBalance),
}, nil
Expand Down
28 changes: 28 additions & 0 deletions waved/operator_negotiation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,34 @@ func TestOperatorTermsFreeRefreshWindow(t *testing.T) {
require.Equal(t, uint32(72), terms.FreeRefreshWindowBlocks)
}

// TestOperatorTermsVTXOConfirmations verifies new operators can advertise a
// shallow VTXO activation depth without weakening boarding-input maturity.
func TestOperatorTermsVTXOConfirmations(t *testing.T) {
t.Parallel()

terms, err := operatorTermsFromResponse(&arkrpc.GetInfoResponse{
Pubkey: testOperatorPubKeyBytes(t),
MinConfirmations: 6,
VtxoConfirmations: 1,
})
require.NoError(t, err)
require.Equal(t, uint32(6), terms.MinConfirmations)
require.Equal(t, uint32(1), terms.VTXOConfirmations)
}

// TestOperatorTermsVTXOConfirmationsLegacyFallback verifies a new client
// preserves the old coupled policy when the additive field is absent.
func TestOperatorTermsVTXOConfirmationsLegacyFallback(t *testing.T) {
t.Parallel()

terms, err := operatorTermsFromResponse(&arkrpc.GetInfoResponse{
Pubkey: testOperatorPubKeyBytes(t),
MinConfirmations: 3,
})
require.NoError(t, err)
require.Equal(t, uint32(3), terms.VTXOConfirmations)
}

// TestNegotiateArkBootstrapZeroSelection proves the client refuses to bootstrap
// when the operator returns a zero selection (no common version, or a
// pre-versioning server). There is no legacy fallback.
Expand Down
1 change: 1 addition & 0 deletions waved/rpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ func (r *RPCServer) GetInfo(ctx context.Context, _ *waverpc.GetInfoRequest) (
FeeRate: uint64(terms.FeeRate),
MinOperatorFee: uint64(terms.MinOperatorFee),
MinConfirmations: terms.MinConfirmations,
VtxoConfirmations: terms.VTXOConfirmations,
MinVtxoAmountSat: uint64(minVTXOAmount),
MaxUserBalance: uint64(terms.MaxUserBalance),
}
Expand Down
2 changes: 2 additions & 0 deletions waved/rpc_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,7 @@ func TestGetInfoIncludesServerInfo(t *testing.T) {
MinOperatorFee: btcutil.Amount(34),
FreeRefreshWindowBlocks: 72,
MinConfirmations: 2,
VTXOConfirmations: 1,
})
r := &RPCServer{server: server}

Expand Down Expand Up @@ -984,6 +985,7 @@ func TestGetInfoIncludesServerInfo(t *testing.T) {
t, uint32(72), resp.ServerInfo.FreeRefreshWindowBlocks,
)
require.Equal(t, uint32(2), resp.ServerInfo.MinConfirmations)
require.Equal(t, uint32(1), resp.ServerInfo.VtxoConfirmations)
}

// TestGetInfoUsesCachedIdentityKey verifies ordinary status reads do not
Expand Down
Loading
Loading