diff --git a/db/round_store.go b/db/round_store.go index 96efd4839..cafaaf85c 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -154,6 +154,12 @@ type RoundStore interface { ctx context.Context, arg sqlc.MarkVTXOForfeitingParams, ) error + // ListForfeitingVTXOsByRound returns the Forfeiting VTXOs bound to a + // round, used to rebuild a reloaded round's forfeit set on restart. + ListForfeitingVTXOsByRound(ctx context.Context, + forfeitRoundID sql.NullString) ( + []sqlc.ListForfeitingVTXOsByRoundRow, error) + GetVTXOForfeitTx(ctx context.Context, arg sqlc.GetVTXOForfeitTxParams) ( sqlc.GetVTXOForfeitTxRow, @@ -922,6 +928,43 @@ func (s *RoundPersistenceStore) MarkVTXOSpent(ctx context.Context, }) } +// roundForfeitRequests rebuilds the forfeit set of a reloaded round from the +// VTXO table. Standard wallet forfeits are the only entries that can survive +// a restart: each Forfeiting VTXO row carries the binding forfeit_round_id, +// while custom (caller-supplied) forfeit inputs never enter the wallet store +// and their in-memory signing contexts die with the process. The rebuilt +// requests carry just the outpoint and amount, which is exactly what the +// status-reconcile release path needs to return the inputs to LiveState. +func roundForfeitRequests(ctx context.Context, q RoundStore, + roundID string) ([]types.ForfeitRequest, error) { + + rows, err := q.ListForfeitingVTXOsByRound(ctx, sql.NullString{ + String: roundID, + Valid: true, + }) + if err != nil { + return nil, fmt.Errorf("list forfeiting vtxos: %w", err) + } + + if len(rows) == 0 { + return nil, nil + } + + forfeits := make([]types.ForfeitRequest, 0, len(rows)) + for _, row := range rows { + var op wire.OutPoint + copy(op.Hash[:], row.OutpointHash) + op.Index = uint32(row.OutpointIndex) + + forfeits = append(forfeits, types.ForfeitRequest{ + VTXOOutpoint: &op, + Amount: btcutil.Amount(row.Amount), + }) + } + + return forfeits, nil +} + // dbRoundToDomainRound converts a database round row to a domain Round struct. func (s *RoundPersistenceStore) dbRoundToDomainRound(ctx context.Context, q RoundStore, dbRound RoundRow, dbIntents []RoundBoardingIntentRow) ( @@ -994,6 +1037,17 @@ func (s *RoundPersistenceStore) dbRoundToDomainRound(ctx context.Context, r.Intents.Boarding = intents } + // Rebuild the forfeit set from the VTXO table. Without this, a + // reloaded forfeit-bearing round looks boarding-only: the actor's + // restart re-arm guard never arms the status-reconcile timer, and a + // DEAD verdict would release an empty set, silently reopening the + // wavelength#844 strand across every restart. + forfeits, err := roundForfeitRequests(ctx, q, dbRound.RoundID) + if err != nil { + return nil, err + } + r.Intents.Forfeits = forfeits + return r, nil } @@ -1268,9 +1322,19 @@ func (s *RoundPersistenceStore) reconstructInputSigSentState( } } + // Rebuild the forfeit set alongside the boarding intents. The + // reconstructed InputSigSentState gates every status-reconcile + // decision on its forfeit count, so leaving Forfeits empty here would + // turn the post-restart reconcile into a no-op. + forfeits, err := roundForfeitRequests(ctx, q, dbRound.RoundID) + if err != nil { + return nil, err + } + state.Intents = round.Intents{ Boarding: intents, VTXOs: vtxos, + Forfeits: forfeits, } state.InputSigs = inputSigs diff --git a/db/round_store_test.go b/db/round_store_test.go index 410fd7dbd..48afbfd81 100644 --- a/db/round_store_test.go +++ b/db/round_store_test.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/wavelength/lib/tree" "github.com/lightninglabs/wavelength/lib/types" "github.com/lightninglabs/wavelength/round" + "github.com/lightninglabs/wavelength/vtxo" "github.com/lightninglabs/wavelength/wallet" "github.com/lightningnetwork/lnd/clock" fn "github.com/lightningnetwork/lnd/fn/v2" @@ -303,6 +304,90 @@ func TestRoundStoreListActiveRounds(t *testing.T) { require.Len(t, activeRounds, 3) } +// TestRoundStoreReloadRebuildsForfeitSet pins the restart half of the +// wavelength#844 fix: a forfeit-bearing round reloaded from the database must +// carry a forfeit set rebuilt from the Forfeiting VTXO rows bound to it via +// forfeit_round_id. Without the rebuild, the reloaded round looks +// boarding-only, so the actor never re-arms the status-reconcile timeout +// after a restart and a dead verdict would release an empty set, silently +// reopening the strand across every restart. +func TestRoundStoreReloadRebuildsForfeitSet(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + roundID := testRoundIDDB("forfeit-reload-round") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: roundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, state)) + + // Bind two wallet VTXOs to the round as in-flight forfeits, exactly + // as the forfeit flow does when the signatures leave the box. A third + // VTXO stays Live to prove the rebuild only picks up Forfeiting rows + // for this round. + descA := createTestVTXODescriptor(t, roundID, 1) + descB := createTestVTXODescriptor(t, roundID, 2) + descLive := createTestVTXODescriptor(t, roundID, 3) + for _, desc := range []*vtxo.Descriptor{descA, descB, descLive} { + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc)) + } + + for _, desc := range []*vtxo.Descriptor{descA, descB} { + require.NoError( + t, + vtxoStore.MarkForfeiting( + ctx, desc.Outpoint, roundID.String(), nil, + ), + ) + } + + wantOutpoints := map[wire.OutPoint]btcutil.Amount{ + descA.Outpoint: descA.Amount, + descB.Outpoint: descB.Amount, + } + + // assertForfeitSet checks a rebuilt forfeit set covers exactly the + // two Forfeiting outpoints with their amounts. + assertForfeitSet := func(forfeits []types.ForfeitRequest) { + t.Helper() + + require.Len(t, forfeits, 2) + for _, req := range forfeits { + require.NotNil(t, req.VTXOOutpoint) + + amount, ok := wantOutpoints[*req.VTXOOutpoint] + require.True(t, ok) + require.Equal(t, amount, req.Amount) + + // The rebuilt requests are standard wallet forfeits: + // no custom spend paths survive a restart, so the + // release path must classify them as standard. + require.Nil(t, req.AuthSpend) + require.Nil(t, req.ForfeitSpend) + } + } + + // The restart reload path (ListActiveRounds) must surface the + // forfeit set on the domain round. + activeRounds, err := roundStore.ListActiveRounds(ctx) + require.NoError(t, err) + require.Len(t, activeRounds, 1) + assertForfeitSet(activeRounds[0].Intents.Forfeits) + + // The reconstructed FSM state must carry the same set, since every + // status-reconcile decision gates on its forfeit count. + _, fsmState, err := roundStore.FetchState(ctx, roundID) + require.NoError(t, err) + + inputSigState, ok := fsmState.(*round.InputSigSentState) + require.True(t, ok) + assertForfeitSet(inputSigState.Intents.Forfeits) +} + // TestRoundStoreFinalizeRound tests finalizing a round. func TestRoundStoreFinalizeRound(t *testing.T) { t.Parallel() diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index d9b8027a0..875610f5e 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -210,6 +210,12 @@ type Querier interface { // rows) instead of decoding the whole activity feed, and the canonical_id // cursor is strictly monotonic (a full page always advances it). ListEntriesByKindStatus(ctx context.Context, arg ListEntriesByKindStatusParams) ([]ActivityEntry, error) + // ListForfeitingVTXOsByRound returns the outpoint and amount of every VTXO + // sitting in Forfeiting status whose forfeit reservation is bound to the + // given round. Used during restart recovery to rebuild a reloaded round's + // forfeit set, so the status-reconcile release path has real outpoints to + // return to Live rather than the empty in-memory set the crash discarded. + ListForfeitingVTXOsByRound(ctx context.Context, forfeitRoundID sql.NullString) ([]ListForfeitingVTXOsByRoundRow, error) // ListLedgerRoundIDsMissingUuid returns the distinct raw round_id BLOBs that // have not yet been mirrored into the round_uuid TEXT column. The BLOB-to-UUID // string conversion is not expressible in the SQL dialect subset shared by diff --git a/db/sqlc/queries/vtxo.sql b/db/sqlc/queries/vtxo.sql index 3dc10103d..bd813c32d 100644 --- a/db/sqlc/queries/vtxo.sql +++ b/db/sqlc/queries/vtxo.sql @@ -82,6 +82,17 @@ SET status = 2, -- Forfeiting last_update_time = $5 WHERE outpoint_hash = $1 AND outpoint_index = $2; +-- name: ListForfeitingVTXOsByRound :many +-- ListForfeitingVTXOsByRound returns the outpoint and amount of every VTXO +-- sitting in Forfeiting status whose forfeit reservation is bound to the +-- given round. Used during restart recovery to rebuild a reloaded round's +-- forfeit set, so the status-reconcile release path has real outpoints to +-- return to Live rather than the empty in-memory set the crash discarded. +SELECT outpoint_hash, outpoint_index, amount +FROM vtxos +WHERE status = 2 -- Forfeiting + AND forfeit_round_id = $1; + -- name: GetVTXOForfeitTx :one -- GetVTXOForfeitTx retrieves the persisted forfeit transaction for a VTXO. -- Used during recovery to restore the ForfeitingState with its tx. diff --git a/db/sqlc/vtxo.sql.go b/db/sqlc/vtxo.sql.go index bd8de9959..6927b56c0 100644 --- a/db/sqlc/vtxo.sql.go +++ b/db/sqlc/vtxo.sql.go @@ -87,6 +87,47 @@ func (q *Queries) GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacement return i, err } +const ListForfeitingVTXOsByRound = `-- name: ListForfeitingVTXOsByRound :many +SELECT outpoint_hash, outpoint_index, amount +FROM vtxos +WHERE status = 2 -- Forfeiting + AND forfeit_round_id = $1 +` + +type ListForfeitingVTXOsByRoundRow struct { + OutpointHash []byte + OutpointIndex int32 + Amount int64 +} + +// ListForfeitingVTXOsByRound returns the outpoint and amount of every VTXO +// sitting in Forfeiting status whose forfeit reservation is bound to the +// given round. Used during restart recovery to rebuild a reloaded round's +// forfeit set, so the status-reconcile release path has real outpoints to +// return to Live rather than the empty in-memory set the crash discarded. +func (q *Queries) ListForfeitingVTXOsByRound(ctx context.Context, forfeitRoundID sql.NullString) ([]ListForfeitingVTXOsByRoundRow, error) { + rows, err := q.db.QueryContext(ctx, ListForfeitingVTXOsByRound, forfeitRoundID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListForfeitingVTXOsByRoundRow + for rows.Next() { + var i ListForfeitingVTXOsByRoundRow + if err := rows.Scan(&i.OutpointHash, &i.OutpointIndex, &i.Amount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListLiveVTXOs = `-- name: ListLiveVTXOs :many SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos WHERE (status < 3 OR status = 7) AND spent = FALSE diff --git a/round/actor.go b/round/actor.go index 86b175b8b..a281cc4eb 100644 --- a/round/actor.go +++ b/round/actor.go @@ -50,6 +50,17 @@ const defaultForfeitCollectionTimeout = 2 * time.Minute // tests that need a different bound. const defaultRegistrationTimeout = 60 * time.Second +// defaultStatusReconcileTimeout bounds how long a forfeit-bearing round sits +// in InputSigSentState with no confirmation, no resolved failure, and no +// status answer before the client probes the operator with a +// QueryRoundStatus (wavelength#844). The window must comfortably exceed the +// operator's input-signature collection phase so a healthy round is not +// probed mid-ceremony; an early probe is harmless (the operator answers +// in-flight and the client keeps waiting), so the constant errs toward +// responsiveness rather than silence. It also serves as the retry interval +// between unanswered probes. +const defaultStatusReconcileTimeout = 90 * time.Second + // defaultRefreshRegistrationDelay is the quiet period used to coalesce // expiry-driven refreshes before registering their round. Block epochs can // make several VTXO actors request refreshes back-to-back; registering the @@ -342,6 +353,15 @@ type RoundClientConfig struct { // admission), which restores the pre-#653 behavior. RegistrationTimeout time.Duration + // StatusReconcileTimeout bounds how long a forfeit-bearing round sits + // in InputSigSentState — forfeit signatures out, no confirmation, no + // resolved failure — before the client probes the operator with a + // QueryRoundStatus (wavelength#844). It doubles as the retry interval + // between probes. If zero, defaultStatusReconcileTimeout is used. A + // negative value disables the reconcile, restoring the pre-#844 + // behavior (a stranded reservation waits for the #823 startup sweep). + StatusReconcileTimeout time.Duration + // OwnedScriptChecker determines whether a VTXO pkScript belongs // to the local wallet. When nil, all VTXOs pass the ownership // check (backward-compatible default for tests). @@ -433,6 +453,14 @@ func NewRoundClientActor(cfg *RoundClientConfig) fn.Result[*RoundClientActor] { } env.RegistrationTimeout = registrationTimeout + // Same zero-selects-default, negative-disables convention as the + // registration timeout. + statusReconcileTimeout := cfg.StatusReconcileTimeout + if statusReconcileTimeout == 0 { + statusReconcileTimeout = defaultStatusReconcileTimeout + } + env.StatusReconcileTimeout = statusReconcileTimeout + actor := &RoundClientActor{ cfg: cfg, log: actorLog, @@ -806,9 +834,10 @@ func (a *RoundClientActor) createRoundFSMFromDB(ctx context.Context, DisableJoinRequestAuth: a.cfg.DisableJoinRequestAuth, ForfeitCollectionTimeout: a. env.ForfeitCollectionTimeout, - RegistrationTimeout: a.env.RegistrationTimeout, - RoundKey: RoundKeyStr(roundID.KeyString()), - OwnedScriptChecker: a.cfg.OwnedScriptChecker, + RegistrationTimeout: a.env.RegistrationTimeout, + StatusReconcileTimeout: a.env.StatusReconcileTimeout, + RoundKey: RoundKeyStr(roundID.KeyString()), + OwnedScriptChecker: a.cfg.OwnedScriptChecker, } fsmCfg := ClientStateMachineCfg{ Logger: fsmLogger, @@ -879,9 +908,10 @@ func (a *RoundClientActor) createNewRound(ctx context.Context) (*RoundFSM, DisableJoinRequestAuth: a.cfg.DisableJoinRequestAuth, ForfeitCollectionTimeout: a. env.ForfeitCollectionTimeout, - RegistrationTimeout: a.env.RegistrationTimeout, - RoundKey: RoundKeyStr(tempKey.KeyString()), - OwnedScriptChecker: a.cfg.OwnedScriptChecker, + RegistrationTimeout: a.env.RegistrationTimeout, + StatusReconcileTimeout: a.env.StatusReconcileTimeout, + RoundKey: RoundKeyStr(tempKey.KeyString()), + OwnedScriptChecker: a.cfg.OwnedScriptChecker, } fsmCfg := ClientStateMachineCfg{ Logger: fsmLogger, @@ -1375,6 +1405,30 @@ func (a *RoundClientActor) Start(ctx context.Context) error { return fmt.Errorf("replay checkpointed messages for "+ "round %s: %w", round.RoundID, err) } + + // A reloaded round sits back in InputSigSentState with its + // forfeit signatures already out, so the wavelength#844 + // hazard window reopens across the restart. Re-arm the + // status-reconcile timeout for forfeit-bearing rounds so a + // round whose failure raced the crash still converges on a + // QueryRoundStatus probe rather than stranding. + if len(round.Intents.Forfeits) > 0 && + a.env.StatusReconcileTimeout > 0 { + + if err := a.processOutbox(ctx, []ClientOutMsg{ + &StartTimeoutReq{ + RoundKey: RoundKeyStr( + round.RoundID.KeyString(), + ), + Phase: TimeoutPhaseStatusReconcile, + Duration: a.env.StatusReconcileTimeout, + }, + }); err != nil { + return fmt.Errorf("arm status reconcile "+ + "timeout for reloaded round %s: %w", + round.RoundID, err) + } + } } a.log.InfoS(ctx, "Round client actor started") @@ -1884,6 +1938,9 @@ func extractRoundID(event ClientEvent) (RoundID, bool) { case *AwaitingBoardingSigs: return e.RoundID, true + case *RoundStatusReported: + return e.RoundID, true + default: return RoundID{}, false } @@ -2385,6 +2442,25 @@ func (a *RoundClientActor) handleTimeout(ctx context.Context, case TimeoutPhaseRegistration: timeoutEvt = &RegistrationTimedOut{} + case TimeoutPhaseStatusReconcile: + // The reconcile timeout is only armed after the round has been + // re-keyed to its server-assigned RoundID (the forfeit + // signatures cannot leave the box before admission), so the + // map key parses back to a RoundID. + roundID, perr := ParseRoundID(string(keyStr)) + if perr != nil { + a.log.WarnS(ctx, "Status reconcile timeout with "+ + "non-RoundID key", + perr, + slog.String("round_key", string(keyStr)), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) + } + timeoutEvt = &StatusReconcileTimedOut{ + RoundID: roundID, + } + default: a.log.WarnS(ctx, "Ignoring timeout with unknown phase", nil, diff --git a/round/events.go b/round/events.go index 5d20d3344..3ee8ff3dd 100644 --- a/round/events.go +++ b/round/events.go @@ -531,3 +531,42 @@ func (e *ForfeitCollectionTimedOut) clientEventSealed() {} type RegistrationTimedOut struct{} func (e *RegistrationTimedOut) clientEventSealed() {} + +// StatusReconcileTimedOut is emitted by the round actor when the +// status-reconcile window expires while the FSM sits in InputSigSentState +// with forfeit signatures already sent. It does not fail the round by +// itself: the FSM reacts by emitting a QueryRoundStatusOutbox probe to the +// operator and re-arming the window, so both a lost failure notification +// and a silent operator (the lumos#618 crash door) eventually converge on +// an authoritative round-status answer (wavelength#844). +type StatusReconcileTimedOut struct { + // RoundID identifies the round whose reconcile window expired. + RoundID RoundID +} + +func (e *StatusReconcileTimedOut) clientEventSealed() {} + +// RoundStatusReported carries the operator's authoritative lifecycle +// answer for a round, in response to a QueryRoundStatusOutbox probe. A +// dead answer is the proof the commitment tx can never confirm, which is +// what makes releasing forfeit reservations after signatures were sent +// double-spend-safe (wavelength#844): the operator persists a finalized +// round atomically with its VTXOs before broadcasting, so a round it has +// no record of never produced a broadcastable commitment. +type RoundStatusReported struct { + // RoundID identifies the round the report is for. + RoundID RoundID + + // Status is the operator's lifecycle classification of the round. + Status roundpb.RoundLifecycleStatus + + // Detail is a free-form diagnostic string (e.g. the failure reason + // for a dead round). + Detail string +} + +func (e *RoundStatusReported) clientEventSealed() {} + +// roundStatusDead aliases the verbose wire enum value so FSM code and its +// tests stay within the line-length limit. +const roundStatusDead = roundpb.RoundLifecycleStatus_ROUND_STATUS_DEAD diff --git a/round/forfeit_release_on_failure_test.go b/round/forfeit_release_on_failure_test.go index 4fe520965..858973d25 100644 --- a/round/forfeit_release_on_failure_test.go +++ b/round/forfeit_release_on_failure_test.go @@ -601,10 +601,13 @@ func TestPreSigningTerminalFailureRetiresJob(t *testing.T) { // TestPostSigningFailureDoesNotReleaseForfeits is the safety control: once the // client has submitted its forfeit signatures (InputSigSentState onward), a -// failed round must NOT auto-release the inputs, since the server could still -// broadcast the forfeit if the commitment confirms. Releasing here would risk a -// double-spend, so the post-signing states are deliberately not wired to the -// release helper. +// failed round must NOT auto-release the inputs on the notification alone, +// since the server could still broadcast the forfeit if the commitment +// confirms. The release instead rides the wavelength#844 status reconcile: the +// failure parks in the state while a QueryRoundStatus probe confirms the round +// is dead at the operator, and only that authoritative answer releases (see +// status_reconcile_test.go). This test pins the notification-alone half: no +// release may ride the BoardingFailed itself. func TestPostSigningFailureDoesNotReleaseForfeits(t *testing.T) { t.Parallel() diff --git a/round/from_proto.go b/round/from_proto.go index 9bc9a1e78..f17b29509 100644 --- a/round/from_proto.go +++ b/round/from_proto.go @@ -672,3 +672,26 @@ var ( type inboundServerMessage interface { FromProto(proto.Message) error } + +// FromProto populates a RoundStatusReported from a ClientRoundStatusReport +// proto. The round id is required: a report that cannot be tied to a round +// is rejected rather than routed by heuristic, because the consumer uses +// the answer to decide whether releasing forfeit reservations is safe. +func (e *RoundStatusReported) FromProto(p proto.Message) error { + pb, ok := p.(*roundpb.ClientRoundStatusReport) + if !ok { + return fmt.Errorf("unexpected proto type: %T, want "+ + "*roundpb.ClientRoundStatusReport", p) + } + + if len(pb.RoundId) != roundIDLen { + return fmt.Errorf("round status report round_id must be %d "+ + "bytes, got %d", roundIDLen, len(pb.RoundId)) + } + + copy(e.RoundID[:], pb.RoundId) + e.Status = pb.Status + e.Detail = pb.Detail + + return nil +} diff --git a/round/fsm_environment.go b/round/fsm_environment.go index 75f3197f2..614a3ae53 100644 --- a/round/fsm_environment.go +++ b/round/fsm_environment.go @@ -85,6 +85,13 @@ type ClientEnvironment struct { // timeout for the round. RegistrationTimeout time.Duration + // StatusReconcileTimeout bounds how long InputSigSentState waits, with + // forfeit signatures already out, before probing the operator with a + // QueryRoundStatus (wavelength#844). It doubles as the retry interval + // between probes. A non-positive value disables the reconcile, leaving + // only the #823 startup sweep to rescue a stranded reservation. + StatusReconcileTimeout time.Duration + // RoundKey is the actor's map key for this round FSM (a TempRoundKey // string before admission, a RoundID string after re-keying). The // registration-timeout outbox messages carry it so the actor can diff --git a/round/fsm_timeouts.go b/round/fsm_timeouts.go index fc594eb27..7c7ed8d31 100644 --- a/round/fsm_timeouts.go +++ b/round/fsm_timeouts.go @@ -19,6 +19,15 @@ const ( // IntentSentState forever, stranding any forfeit-reserved VTXOs in // pending-forfeit (see wavelength#653). TimeoutPhaseRegistration TimeoutPhase = "registration" + + // TimeoutPhaseStatusReconcile is the timeout phase for + // InputSigSentState's round-status reconcile (wavelength#844). It is + // armed when the forfeit signatures leave the box and re-armed on + // every reconcile probe, so both a received round failure and total + // operator silence (the lumos#618 crash door) eventually drive a + // QueryRoundStatus. The reservation is released only on an + // authoritative dead answer, never on the timeout alone. + TimeoutPhaseStatusReconcile TimeoutPhase = "status-reconcile" ) // cancelForfeitTimeout builds a single-element outbox slice that @@ -31,3 +40,47 @@ func cancelForfeitTimeout(roundID RoundID) []ClientOutMsg { }, } } + +// statusReconcileMaxBackoffShift caps the exponential backoff applied to +// repeated reconcile probes at base<<4 (16x, 24 minutes at the 90 second +// default). An operator that predates the QueryRoundStatus RPC never +// answers, so without a ceiling the doubling would push the next probe +// arbitrarily far out; with one, the client keeps converging on a bounded, +// low-rate probe cadence for as long as the reservation is parked. +const statusReconcileMaxBackoffShift = 4 + +// statusReconcileProbeOutbox builds the outbox pair for one round-status +// reconcile probe: the QueryRoundStatus ask to the operator, plus a +// re-arm of the status-reconcile timeout so an unanswered probe retries. +// Scheduling a timeout under an existing ID replaces it, so re-arming +// from a probe never stacks timers. The re-arm duration doubles with each +// unanswered probe (capped by statusReconcileMaxBackoffShift), bounding +// the probe traffic aimed at an operator that never answers, e.g. one +// running a release that predates the QueryRoundStatus RPC. +func statusReconcileProbeOutbox(roundID RoundID, env *ClientEnvironment, + probes uint32) []ClientOutMsg { + + shift := min(probes, statusReconcileMaxBackoffShift) + duration := env.StatusReconcileTimeout << shift + + return []ClientOutMsg{ + &QueryRoundStatusOutbox{ + RoundID: roundID, + }, + &StartTimeoutReq{ + RoundKey: RoundKeyStr(roundID.KeyString()), + Phase: TimeoutPhaseStatusReconcile, + Duration: duration, + }, + } +} + +// cancelStatusReconcileTimeout builds the outbox message that disarms the +// status-reconcile timeout, for the paths that resolve the round's fate +// (confirmation, or an authoritative dead answer). +func cancelStatusReconcileTimeout(roundID RoundID) ClientOutMsg { + return &CancelTimeoutReq{ + RoundKey: RoundKeyStr(roundID.KeyString()), + Phase: TimeoutPhaseStatusReconcile, + } +} diff --git a/round/outbox_messages.go b/round/outbox_messages.go index f5c4db62b..1189e801c 100644 --- a/round/outbox_messages.go +++ b/round/outbox_messages.go @@ -991,3 +991,41 @@ type TerminalJobFailedNotification struct { } func (m *TerminalJobFailedNotification) clientOutMsgSealed() {} + +// QueryRoundStatusOutbox is emitted by InputSigSentState to probe the +// operator for a round's authoritative lifecycle status (wavelength#844). +// It fires when a round failure arrives after forfeit signatures have been +// sent, and again on every status-reconcile timeout, so a lost failure +// notification or a silent operator both converge on an answer. Routed via +// the durable mailbox to the server's MethodQueryRoundStatus handler; the +// answer comes back asynchronously as a ClientRoundStatusReport push event. +type QueryRoundStatusOutbox struct { + actor.BaseMessage + + // RoundID is the round whose status is being queried. + RoundID RoundID +} + +func (m *QueryRoundStatusOutbox) clientOutMsgSealed() {} + +// ServiceMethod returns the mailbox routing metadata for +// QueryRoundStatusOutbox. +func (m *QueryRoundStatusOutbox) ServiceMethod() mailboxrpc.ServiceMethod { + return mailboxrpc.ServiceMethod{ + Service: roundpb.ServiceName, + Method: roundpb.MethodQueryRoundStatus, + } +} + +// CorrelationKey returns the per-round FIFO key so the probe lands in +// emission order with the rest of the round's outbox events. +func (m *QueryRoundStatusOutbox) CorrelationKey() string { + return roundCorrelationKey(m.RoundID.String()) +} + +// ToProto converts QueryRoundStatusOutbox to the roundpb wire format. +func (m *QueryRoundStatusOutbox) ToProto() fn.Result[proto.Message] { + return fn.Ok[proto.Message](&roundpb.QueryRoundStatusRequest{ + RoundId: append([]byte(nil), m.RoundID[:]...), + }) +} diff --git a/round/states.go b/round/states.go index 89edacc38..158de71ca 100644 --- a/round/states.go +++ b/round/states.go @@ -692,6 +692,24 @@ type InputSigSentState struct { // round confirms, ForfeitConfirmedToVTXO messages are emitted for each // so old VTXO actors can transition to the Forfeited terminal state. ForfeitedVTXOs []wire.OutPoint + + // PendingFailure carries a round-failure notification received while + // forfeit signatures are already out (wavelength#844). The FSM cannot + // fail the round on the notification alone — the operator may hold + // fully-signed forfeit txs, so releasing the reservations needs proof + // the commitment can never confirm — so the failure is parked here + // while a QueryRoundStatus probe reconciles the round's fate. It is + // in-memory only: a restart re-enters the reconcile from scratch via + // the re-armed status-reconcile timeout. + PendingFailure *BoardingFailed + + // ReconcileProbes counts the QueryRoundStatus probes sent for this + // round so the re-arm duration can back off exponentially against an + // operator that never answers (e.g. one predating the status RPC). + // Like PendingFailure it is in-memory only; a restart resets the + // backoff, which just means the first post-restart probe fires + // promptly again. + ReconcileProbes uint32 } func (s *InputSigSentState) String() string { diff --git a/round/status_reconcile_test.go b/round/status_reconcile_test.go new file mode 100644 index 000000000..f2a98bbb9 --- /dev/null +++ b/round/status_reconcile_test.go @@ -0,0 +1,428 @@ +package round + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/rpc/roundpb" + "github.com/stretchr/testify/require" +) + +// reconcileRoundID builds a deterministic round id for the reconcile tests. +func reconcileRoundID(seed byte) RoundID { + var id RoundID + id[0] = seed + + return id +} + +// reconcileState builds an InputSigSentState carrying the given forfeits, +// mirroring the checkpointed point-of-no-return state of a refresh round. +func reconcileState(roundID RoundID, + forfeits []types.ForfeitRequest) *InputSigSentState { + + return &InputSigSentState{ + RoundID: roundID, + Intents: Intents{ + Forfeits: forfeits, + }, + } +} + +// reconcileEnv builds a minimal environment with the status reconcile +// enabled. +func reconcileEnv() *ClientEnvironment { + return &ClientEnvironment{ + Log: btclog.Disabled, + StatusReconcileTimeout: time.Minute, + } +} + +// reconcileOutpoint builds a deterministic VTXO outpoint. +func reconcileOutpoint(seed byte) wire.OutPoint { + return wire.OutPoint{ + Hash: chainhash.Hash{ + seed, + }, + Index: 0, + } +} + +// TestPostSigningFailureParksAndProbes is the wavelength#844 core: a round +// failure arriving in InputSigSentState with forfeit signatures already out +// must NOT fail the round or release the reservations on the notification +// alone. The FSM parks the failure in the state and probes the operator +// with a QueryRoundStatus, arming the reconcile retry timeout. +func TestPostSigningFailureParksAndProbes(t *testing.T) { + t.Parallel() + + roundID := reconcileRoundID(0xa1) + s := reconcileState(roundID, []types.ForfeitRequest{ + mkForfeit(reconcileOutpoint(0x01), 10_000), + }) + + failure := &BoardingFailed{ + Reason: "input signature collection timeout", + Recoverable: true, + } + + tr, err := s.ProcessEvent(context.Background(), failure, reconcileEnv()) + require.NoError(t, err) + + // The round must still be in InputSigSentState with the failure + // parked, not failed. + next, ok := tr.NextState.(*InputSigSentState) + require.True(t, ok, "expected InputSigSentState, got %T", tr.NextState) + require.NotNil(t, next.PendingFailure) + require.Equal(t, failure.Reason, next.PendingFailure.Reason) + + outbox := tr.NewEvents.UnwrapOr(ClientEmittedEvent{}).Outbox + + // A probe went out and the retry window was armed. + probe, ok := findOutbox[*QueryRoundStatusOutbox](outbox) + require.True(t, ok, "no QueryRoundStatusOutbox emitted") + require.Equal(t, roundID, probe.RoundID) + + timeoutReq, ok := findOutbox[*StartTimeoutReq](outbox) + require.True(t, ok, "no StartTimeoutReq emitted") + require.Equal(t, TimeoutPhaseStatusReconcile, timeoutReq.Phase) + + // Crucially, NO release rode the notification. + _, released := findOutbox[*ReleaseForfeitReservation](outbox) + require.False(t, released, "release emitted on unreconciled failure") +} + +// TestPostSigningFailureNoForfeitsFailsImmediately pins the boarding-only +// behavior: with no forfeit reservations at stake there is nothing to +// strand, so a round failure in InputSigSentState fails the round +// immediately exactly as before the reconcile existed. +func TestPostSigningFailureNoForfeitsFailsImmediately(t *testing.T) { + t.Parallel() + + s := reconcileState(reconcileRoundID(0xa2), nil) + + failure := &BoardingFailed{ + Reason: "round failed", + Recoverable: true, + } + + tr, err := s.ProcessEvent(context.Background(), failure, reconcileEnv()) + require.NoError(t, err) + + failed, ok := tr.NextState.(*ClientFailedState) + require.True(t, ok, "expected ClientFailedState, got %T", tr.NextState) + require.Equal(t, failure.Reason, failed.Reason) +} + +// TestPostSigningFailureReconcileDisabledFailsImmediately pins the opt-out: +// a non-positive StatusReconcileTimeout restores the pre-#844 behavior of +// failing straight into ClientFailedState with no release (the #823 +// startup sweep remains the only rescue). +func TestPostSigningFailureReconcileDisabledFailsImmediately(t *testing.T) { + t.Parallel() + + s := reconcileState(reconcileRoundID(0xa3), []types.ForfeitRequest{ + mkForfeit(reconcileOutpoint(0x02), 10_000), + }) + env := &ClientEnvironment{ + Log: btclog.Disabled, + StatusReconcileTimeout: -1, + } + + tr, err := s.ProcessEvent( + context.Background(), &BoardingFailed{ + Reason: "round failed", + Recoverable: true, + }, + env, + ) + require.NoError(t, err) + + _, ok := tr.NextState.(*ClientFailedState) + require.True(t, ok, "expected ClientFailedState, got %T", tr.NextState) +} + +// TestDeadStatusFailsAndReleases proves the reconciled release: an +// authoritative ROUND_STATUS_DEAD answer fails the round with the parked +// failure and emits the ReleaseForfeitReservation returning the inputs to +// LiveState, plus the reconcile-timeout cancel. +func TestDeadStatusFailsAndReleases(t *testing.T) { + t.Parallel() + + roundID := reconcileRoundID(0xa4) + op := reconcileOutpoint(0x03) + s := reconcileState(roundID, []types.ForfeitRequest{ + mkForfeit(op, 10_000), + }) + s.PendingFailure = &BoardingFailed{ + Reason: "input signature collection timeout", + Recoverable: true, + } + + tr, err := s.ProcessEvent( + context.Background(), + &RoundStatusReported{ + RoundID: roundID, + Status: roundpb.RoundLifecycleStatus_ROUND_STATUS_DEAD, + }, + reconcileEnv(), + ) + require.NoError(t, err) + + failed, ok := tr.NextState.(*ClientFailedState) + require.True(t, ok, "expected ClientFailedState, got %T", tr.NextState) + require.Equal(t, s.PendingFailure.Reason, failed.Reason) + require.True(t, failed.Recoverable) + + outbox := tr.NewEvents.UnwrapOr(ClientEmittedEvent{}).Outbox + + release, ok := findOutbox[*ReleaseForfeitReservation](outbox) + require.True(t, ok, "no ReleaseForfeitReservation emitted") + require.Equal(t, []wire.OutPoint{op}, release.Outpoints) + + cancel, ok := findOutbox[*CancelTimeoutReq](outbox) + require.True(t, ok, "no CancelTimeoutReq emitted") + require.Equal(t, TimeoutPhaseStatusReconcile, cancel.Phase) +} + +// TestDeadStatusWithoutParkedFailureSynthesizesReason covers the lumos#618 +// silence door: the round died with no failure notification at all (server +// crash), so the dead answer itself must carry the round into a +// recoverable failure with the release. +func TestDeadStatusWithoutParkedFailureSynthesizesReason(t *testing.T) { + t.Parallel() + + roundID := reconcileRoundID(0xa5) + op := reconcileOutpoint(0x04) + s := reconcileState(roundID, []types.ForfeitRequest{ + mkForfeit(op, 10_000), + }) + + tr, err := s.ProcessEvent( + context.Background(), + &RoundStatusReported{ + RoundID: roundID, + Status: roundpb.RoundLifecycleStatus_ROUND_STATUS_DEAD, + Detail: "round unknown to operator", + }, + reconcileEnv(), + ) + require.NoError(t, err) + + failed, ok := tr.NextState.(*ClientFailedState) + require.True(t, ok, "expected ClientFailedState, got %T", tr.NextState) + require.Equal(t, "round unknown to operator", failed.Reason) + require.True(t, failed.Recoverable) + + outbox := tr.NewEvents.UnwrapOr(ClientEmittedEvent{}).Outbox + _, released := findOutbox[*ReleaseForfeitReservation](outbox) + require.True(t, released, "no release on dead answer") +} + +// TestNonDeadStatusHoldsReservations pins the safety half: any answer other +// than dead (in-flight, broadcast, confirmed) means the commitment may +// still confirm, so the FSM must hold the reservations and keep waiting. +func TestNonDeadStatusHoldsReservations(t *testing.T) { + t.Parallel() + + statuses := []roundpb.RoundLifecycleStatus{ + roundpb.RoundLifecycleStatus_ROUND_STATUS_IN_FLIGHT, + roundpb.RoundLifecycleStatus_ROUND_STATUS_BROADCAST, + roundpb.RoundLifecycleStatus_ROUND_STATUS_CONFIRMED, + roundpb.RoundLifecycleStatus_ROUND_STATUS_UNSPECIFIED, + } + + for _, status := range statuses { + roundID := reconcileRoundID(0xa6) + s := reconcileState(roundID, []types.ForfeitRequest{ + mkForfeit(reconcileOutpoint(0x05), 10_000), + }) + s.PendingFailure = &BoardingFailed{Reason: "parked"} + + tr, err := s.ProcessEvent( + context.Background(), + &RoundStatusReported{ + RoundID: roundID, + Status: status, + }, + reconcileEnv(), + ) + require.NoError(t, err) + + next, ok := tr.NextState.(*InputSigSentState) + require.True( + t, ok, "status %v: expected InputSigSentState, got %T", + status, tr.NextState, + ) + require.NotNil( + t, next.PendingFailure, "status %v: parked failure "+ + "lost", status, + ) + + outbox := tr.NewEvents.UnwrapOr(ClientEmittedEvent{}).Outbox + _, released := findOutbox[*ReleaseForfeitReservation](outbox) + require.False( + t, released, "status %v: released on a non-dead answer", + status, + ) + } +} + +// TestMismatchedReportIgnored pins the routing guard: a status report for a +// different round must not touch this round's state. +func TestMismatchedReportIgnored(t *testing.T) { + t.Parallel() + + s := reconcileState(reconcileRoundID(0xa7), []types.ForfeitRequest{ + mkForfeit(reconcileOutpoint(0x06), 10_000), + }) + + tr, err := s.ProcessEvent( + context.Background(), + &RoundStatusReported{ + RoundID: reconcileRoundID(0xff), + Status: roundpb.RoundLifecycleStatus_ROUND_STATUS_DEAD, + }, + reconcileEnv(), + ) + require.NoError(t, err) + + _, ok := tr.NextState.(*InputSigSentState) + require.True(t, ok, "expected InputSigSentState, got %T", tr.NextState) + + outbox := tr.NewEvents.UnwrapOr(ClientEmittedEvent{}).Outbox + _, released := findOutbox[*ReleaseForfeitReservation](outbox) + require.False(t, released, "released on a mismatched report") +} + +// TestReconcileTimeoutReprobes pins the retry loop that covers both the +// lost-answer case and the lumos#618 silence door: every expiry of the +// reconcile window re-emits the probe and re-arms the window, and never +// fails the round by itself. +func TestReconcileTimeoutReprobes(t *testing.T) { + t.Parallel() + + roundID := reconcileRoundID(0xa8) + s := reconcileState(roundID, []types.ForfeitRequest{ + mkForfeit(reconcileOutpoint(0x07), 10_000), + }) + + tr, err := s.ProcessEvent( + context.Background(), &StatusReconcileTimedOut{ + RoundID: roundID, + }, + reconcileEnv(), + ) + require.NoError(t, err) + + _, ok := tr.NextState.(*InputSigSentState) + require.True(t, ok, "expected InputSigSentState, got %T", tr.NextState) + + outbox := tr.NewEvents.UnwrapOr(ClientEmittedEvent{}).Outbox + + probe, ok := findOutbox[*QueryRoundStatusOutbox](outbox) + require.True(t, ok, "no re-probe emitted") + require.Equal(t, roundID, probe.RoundID) + + timeoutReq, ok := findOutbox[*StartTimeoutReq](outbox) + require.True(t, ok, "reconcile window not re-armed") + require.Equal(t, TimeoutPhaseStatusReconcile, timeoutReq.Phase) + + _, released := findOutbox[*ReleaseForfeitReservation](outbox) + require.False(t, released, "released on a bare timeout") +} + +// TestReconcileReprobeBacksOff proves the re-arm duration doubles with each +// unanswered probe and caps at base< InputSigSentState transition): until that -// point the server holds no forfeit signature and cannot broadcast a forfeit, -// so returning the inputs to LiveState cannot double-spend. Callers therefore -// wire this only into the pre-signing states (PendingRoundAssembly through -// ForfeitSignaturesCollectingState); the post-signing states (InputSigSentState -// onward) deliberately do not release. +// Rolling back is unconditionally safe BEFORE the client submits its VTXO +// forfeit signatures to the server (SubmitVTXOForfeitSigsToServer, emitted on +// the ForfeitSignaturesCollectingState -> InputSigSentState transition): until +// that point the server holds no forfeit signature and cannot broadcast a +// forfeit, so returning the inputs to LiveState cannot double-spend. The +// pre-signing states (PendingRoundAssembly through +// ForfeitSignaturesCollectingState) therefore wire this into every failure. +// Past that point the wrapper has exactly one caller: InputSigSentState's +// dead-answer path (wavelength#844), where a RoundStatusReported carrying the +// operator's authoritative dead verdict proves the round never finalized, its +// commitment can never confirm, and the forfeit signatures the operator may +// hold are unspendable, restoring the same cannot-double-spend invariant. No +// post-signing failure releases without that verdict. // // Rollback messages are prepended (not appended) so they are the first items // processOutbox dispatches. The local vtxo-manager rollbacks are handled @@ -183,8 +188,9 @@ func releaseForfeitsOnFailure(transition *ClientStateTransition, err error, // failed. This is orthogonal to the release above: a handler that // already rolled back still needs the job retired, so we key only on // whether the drop is already present, not on whether we performed the - // release. This runs only in the pre-signing states this wrapper - // guards, where returning the inputs to LiveState cannot double-spend. + // release. The release itself is safe in every state this wrapper + // guards: unconditionally pre-signing, and in InputSigSentState only + // behind the operator's dead verdict (see the function comment). if failedState.FailureCode.IsTerminalForJob() && !alreadyNotified { emitted.Outbox = append(emitted.Outbox, &TerminalJobFailedNotification{ @@ -2898,6 +2904,22 @@ func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox( }, } + // The forfeit signatures leave the box on this transition, opening the + // wavelength#844 hazard window: from here on, a round failure (or a + // silent operator, the lumos#618 crash door) must resolve through a + // status reconcile before the reservations can be released. Arm the + // reconcile timeout so total silence still converges on a probe. A + // boarding-only round has no reservations to reconcile, so it skips + // the timer entirely, matching the forfeit-count gate every consumer + // of the timeout applies. + if len(s.Intents.Forfeits) > 0 && env.StatusReconcileTimeout > 0 { + outboxMsgs = append(outboxMsgs, &StartTimeoutReq{ + RoundKey: RoundKeyStr(s.RoundID.KeyString()), + Phase: TimeoutPhaseStatusReconcile, + Duration: env.StatusReconcileTimeout, + }) + } + if len(boardingInputSigs) == 0 { return outboxMsgs } @@ -4320,6 +4342,8 @@ func buildClientVTXOs(ctx context.Context, checker OwnedScriptChecker, } // ProcessEvent for InputSigSentState. +// +//nolint:funlen func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, env *ClientEnvironment) (*ClientStateTransition, error) { @@ -4333,14 +4357,166 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, slog.String("reason", evt.Reason), ) + // With no forfeit reservations at stake (a boarding-only + // round), nothing can strand and nothing was signed away, so + // the round fails immediately as before. + if len(s.Intents.Forfeits) == 0 || + env.StatusReconcileTimeout <= 0 { + return &ClientStateTransition{ + NextState: &ClientFailedState{ + Reason: evt.Reason, + Error: evt.Error, + Recoverable: evt.Recoverable, + FailureCode: evt.FailureCode, + }, + }, nil + } + + // Forfeit signatures are already out, so the notification alone + // cannot justify releasing the reservations: the operator holds + // fully-signed forfeit txs, and a release is double-spend-safe + // only once the round's commitment can never confirm + // (wavelength#844). Park the failure and probe the operator for + // the round's authoritative status; only a dead answer fails + // the round and releases. + env.Log.InfoS(ctx, "Reconciling round status before releasing "+ + "forfeit reservations", + slog.String("round_id", s.RoundID.String()), + slog.Int("forfeit_count", len(s.Intents.Forfeits)), + ) + + next := *s + next.PendingFailure = evt + next.ReconcileProbes = 1 + + return &ClientStateTransition{ + NextState: &next, + NewEvents: fn.Some(ClientEmittedEvent{ + Outbox: statusReconcileProbeOutbox( + s.RoundID, env, 0, + ), + }), + }, nil + + case *StatusReconcileTimedOut: + // The reconcile window expired with no confirmation, no + // failure resolution, and no status answer. Probe (again) and + // re-arm. The timeout alone never fails the round: with + // forfeit signatures out, only an authoritative dead answer + // makes the release safe. With no forfeits at stake the + // timeout should not even be armed; self-loop defensively. + if len(s.Intents.Forfeits) == 0 || + env.StatusReconcileTimeout <= 0 { + return selfLoop(s), nil + } + + env.Log.InfoS(ctx, "Status reconcile window expired, probing "+ + "operator for round status", + slog.String("round_id", s.RoundID.String()), + slog.Uint64("probes", uint64(s.ReconcileProbes)), + ) + + next := *s + next.ReconcileProbes++ + return &ClientStateTransition{ + NextState: &next, + NewEvents: fn.Some(ClientEmittedEvent{ + Outbox: statusReconcileProbeOutbox( + s.RoundID, env, s.ReconcileProbes, + ), + }), + }, nil + + case *RoundStatusReported: + if evt.RoundID != s.RoundID { + return selfLoop(s), nil + } + + if evt.Status != roundStatusDead { + // The round is in flight, broadcast, or confirmed: the + // commitment may still confirm, so the forfeit + // reservations must hold. Confirmation handling stays + // with the registered confirmation notifier; the + // re-armed reconcile timeout keeps probing if nothing + // resolves. + env.Log.InfoS(ctx, "Round status probe answered; "+ + "round not dead, holding forfeit reservations", + slog.String("round_id", s.RoundID.String()), + slog.String("status", evt.Status.String()), + ) + + return selfLoop(s), nil + } + + // The operator has no live FSM and no durable record of this + // round. A finalized round is persisted atomically with its + // VTXOs before its commitment is ever broadcast, so a dead + // answer proves the commitment can never confirm and the + // forfeit signatures the operator may hold are unspendable. + // Fail the round and release the reservations. + // + // Trust boundary: that proof holds for an honest-but-faulty + // operator, the failure mode this reconcile exists for. An + // operator that lies dead while secretly holding a + // broadcastable commitment can race the released input's next + // spend on chain; the alternative (never releasing without an + // on-chain proof of death, which absence cannot provide) is + // the permanent #844 strand for every honest failure. We + // accept the operator's self-report here and keep the + // commitment confirmation watch registered at checkpoint, so + // a later fraudulent broadcast still surfaces as a detected + // conflict rather than passing silently. + failure := s.PendingFailure + if failure == nil { + reason := "round dead at operator" + if evt.Detail != "" { + reason = evt.Detail + } + + // The zero FailureCode is deliberate: pure silence + // (the lumos#618 door) carries no typed cause, and a + // non-terminal-for-job code keeps the persisted + // pending intent in recoverable replay, so the job + // retries on a fresh round and simply re-reserves the + // just-released inputs. + failure = &BoardingFailed{ + RoundID: fn.Some(s.RoundID), + Reason: reason, + Recoverable: true, + } + } + + env.Log.WarnS(ctx, "Round confirmed dead by operator status "+ + "probe; failing round and releasing forfeit "+ + "reservations", + nil, + slog.String("round_id", s.RoundID.String()), + slog.String("reason", failure.Reason), + slog.Int("forfeit_count", len(s.Intents.Forfeits)), + ) + + transition := &ClientStateTransition{ NextState: &ClientFailedState{ - Reason: evt.Reason, - Error: evt.Error, - Recoverable: evt.Recoverable, - FailureCode: evt.FailureCode, + Reason: failure.Reason, + Error: failure.Error, + Recoverable: failure.Recoverable, + FailureCode: failure.FailureCode, }, - }, nil + NewEvents: fn.Some(ClientEmittedEvent{ + Outbox: []ClientOutMsg{ + cancelStatusReconcileTimeout(s.RoundID), + }, + }), + } + + // The release is safe here for the same reason it is safe in + // the pre-signing states: the commitment can never confirm. + // The wrapper also retires the originating job on a + // terminal-for-job failure code. + return releaseForfeitsOnFailure( + transition, nil, fn.Some(s.RoundID), s.Intents.Forfeits, + ) case *BoardingConfirmed: env.Log.InfoS(ctx, "Commitment transaction confirmed", @@ -4427,7 +4603,16 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, outflows := roundLedgerOutflows(s.RoundID, s.Intents) // Build outbox messages starting with standard notifications. - outbox := make([]ClientOutMsg, 0, 2) + // The confirmation resolves the round's fate, so any armed + // status-reconcile probe is disarmed first. + outbox := make([]ClientOutMsg, 0, 3) + if len(s.Intents.Forfeits) > 0 && + env.StatusReconcileTimeout > 0 { + + outbox = append( + outbox, cancelStatusReconcileTimeout(s.RoundID), + ) + } if len(vtxos) > 0 || len(outflows) > 0 || operatorFee > 0 { outbox = append(outbox, &VTXOCreatedNotification{ VTXOs: vtxos, diff --git a/rpc/roundpb/round.pb.go b/rpc/roundpb/round.pb.go index 144d7152a..616d8ab9e 100644 --- a/rpc/roundpb/round.pb.go +++ b/rpc/roundpb/round.pb.go @@ -82,6 +82,78 @@ func (RoundFailureCode) EnumDescriptor() ([]byte, []int) { return file_round_proto_rawDescGZIP(), []int{0} } +// RoundLifecycleStatus classifies where a round sits in its lifecycle +// from the operator's authoritative view: the live FSM first, then the +// durable round store. Because a finalized round is persisted atomically +// with its VTXOs before its commitment tx is ever broadcast, a round the +// operator does not know (ROUND_STATUS_DEAD) can never have produced a +// broadcastable commitment. +type RoundLifecycleStatus int32 + +const ( + // ROUND_STATUS_UNSPECIFIED is the zero value; treat as unknown. + RoundLifecycleStatus_ROUND_STATUS_UNSPECIFIED RoundLifecycleStatus = 0 + // ROUND_STATUS_IN_FLIGHT: a live FSM exists and the round has not + // reached a terminal state; the commitment tx has not been broadcast. + RoundLifecycleStatus_ROUND_STATUS_IN_FLIGHT RoundLifecycleStatus = 1 + // ROUND_STATUS_BROADCAST: the round finalized and its commitment tx + // has been broadcast, but has not yet confirmed. + RoundLifecycleStatus_ROUND_STATUS_BROADCAST RoundLifecycleStatus = 2 + // ROUND_STATUS_CONFIRMED: the round's commitment tx confirmed + // on chain. + RoundLifecycleStatus_ROUND_STATUS_CONFIRMED RoundLifecycleStatus = 3 + // ROUND_STATUS_DEAD: the round failed, or the operator has no record + // of it at all. Either way it never finalized, so its commitment tx + // can never confirm and any forfeit signatures the client produced + // for it are unspendable. + RoundLifecycleStatus_ROUND_STATUS_DEAD RoundLifecycleStatus = 4 +) + +// Enum value maps for RoundLifecycleStatus. +var ( + RoundLifecycleStatus_name = map[int32]string{ + 0: "ROUND_STATUS_UNSPECIFIED", + 1: "ROUND_STATUS_IN_FLIGHT", + 2: "ROUND_STATUS_BROADCAST", + 3: "ROUND_STATUS_CONFIRMED", + 4: "ROUND_STATUS_DEAD", + } + RoundLifecycleStatus_value = map[string]int32{ + "ROUND_STATUS_UNSPECIFIED": 0, + "ROUND_STATUS_IN_FLIGHT": 1, + "ROUND_STATUS_BROADCAST": 2, + "ROUND_STATUS_CONFIRMED": 3, + "ROUND_STATUS_DEAD": 4, + } +) + +func (x RoundLifecycleStatus) Enum() *RoundLifecycleStatus { + p := new(RoundLifecycleStatus) + *p = x + return p +} + +func (x RoundLifecycleStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RoundLifecycleStatus) Descriptor() protoreflect.EnumDescriptor { + return file_round_proto_enumTypes[1].Descriptor() +} + +func (RoundLifecycleStatus) Type() protoreflect.EnumType { + return &file_round_proto_enumTypes[1] +} + +func (x RoundLifecycleStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RoundLifecycleStatus.Descriptor instead. +func (RoundLifecycleStatus) EnumDescriptor() ([]byte, []int) { + return file_round_proto_rawDescGZIP(), []int{1} +} + // QuoteReason classifies why a JoinRoundQuote failed to admit the // client's intent. When reject_reason != QUOTE_OK the quote's // vtxo_quotes / leave_quotes lists are empty and the client is @@ -128,11 +200,11 @@ func (x QuoteReason) String() string { } func (QuoteReason) Descriptor() protoreflect.EnumDescriptor { - return file_round_proto_enumTypes[1].Descriptor() + return file_round_proto_enumTypes[2].Descriptor() } func (QuoteReason) Type() protoreflect.EnumType { - return &file_round_proto_enumTypes[1] + return &file_round_proto_enumTypes[2] } func (x QuoteReason) Number() protoreflect.EnumNumber { @@ -141,7 +213,7 @@ func (x QuoteReason) Number() protoreflect.EnumNumber { // Deprecated: Use QuoteReason.Descriptor instead. func (QuoteReason) EnumDescriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{1} + return file_round_proto_rawDescGZIP(), []int{2} } // Outpoint identifies a transaction output by its transaction hash and output @@ -1111,6 +1183,74 @@ func (x *ClientErrorResp) GetErrorMsg() string { return "" } +// ClientRoundStatusReport answers a QueryRoundStatusRequest with the +// operator's authoritative view of a round's lifecycle state. The +// client uses ROUND_STATUS_DEAD as the proof-of-death that makes a +// post-signing forfeit-reservation release safe. +type ClientRoundStatusReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + // round_id is the UUID of the round (16 bytes). + RoundId []byte `protobuf:"bytes,1,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"` + // status is the round's lifecycle classification. + Status RoundLifecycleStatus `protobuf:"varint,2,opt,name=status,proto3,enum=round.v1.RoundLifecycleStatus" json:"status,omitempty"` + // detail is a free-form diagnostic string (e.g. the failure reason + // for a dead round). + Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientRoundStatusReport) Reset() { + *x = ClientRoundStatusReport{} + mi := &file_round_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientRoundStatusReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientRoundStatusReport) ProtoMessage() {} + +func (x *ClientRoundStatusReport) ProtoReflect() protoreflect.Message { + mi := &file_round_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientRoundStatusReport.ProtoReflect.Descriptor instead. +func (*ClientRoundStatusReport) Descriptor() ([]byte, []int) { + return file_round_proto_rawDescGZIP(), []int{13} +} + +func (x *ClientRoundStatusReport) GetRoundId() []byte { + if x != nil { + return x.RoundId + } + return nil +} + +func (x *ClientRoundStatusReport) GetStatus() RoundLifecycleStatus { + if x != nil { + return x.Status + } + return RoundLifecycleStatus_ROUND_STATUS_UNSPECIFIED +} + +func (x *ClientRoundStatusReport) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + // BoardingRequest represents a request to board the Ark via a UTXO. type BoardingRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1128,7 +1268,7 @@ type BoardingRequest struct { func (x *BoardingRequest) Reset() { *x = BoardingRequest{} - mi := &file_round_proto_msgTypes[13] + mi := &file_round_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1140,7 +1280,7 @@ func (x *BoardingRequest) String() string { func (*BoardingRequest) ProtoMessage() {} func (x *BoardingRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[13] + mi := &file_round_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1153,7 +1293,7 @@ func (x *BoardingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardingRequest.ProtoReflect.Descriptor instead. func (*BoardingRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{13} + return file_round_proto_rawDescGZIP(), []int{14} } func (x *BoardingRequest) GetOutpoint() *Outpoint { @@ -1223,7 +1363,7 @@ type VTXORequest struct { func (x *VTXORequest) Reset() { *x = VTXORequest{} - mi := &file_round_proto_msgTypes[14] + mi := &file_round_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1235,7 +1375,7 @@ func (x *VTXORequest) String() string { func (*VTXORequest) ProtoMessage() {} func (x *VTXORequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[14] + mi := &file_round_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1248,7 +1388,7 @@ func (x *VTXORequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VTXORequest.ProtoReflect.Descriptor instead. func (*VTXORequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{14} + return file_round_proto_rawDescGZIP(), []int{15} } func (x *VTXORequest) GetTargetAmountSat() int64 { @@ -1308,7 +1448,7 @@ type ForfeitRequest struct { func (x *ForfeitRequest) Reset() { *x = ForfeitRequest{} - mi := &file_round_proto_msgTypes[15] + mi := &file_round_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1320,7 +1460,7 @@ func (x *ForfeitRequest) String() string { func (*ForfeitRequest) ProtoMessage() {} func (x *ForfeitRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[15] + mi := &file_round_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1333,7 +1473,7 @@ func (x *ForfeitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ForfeitRequest.ProtoReflect.Descriptor instead. func (*ForfeitRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{15} + return file_round_proto_rawDescGZIP(), []int{16} } func (x *ForfeitRequest) GetVtxoOutpoint() *Outpoint { @@ -1381,7 +1521,7 @@ type LeaveRequest struct { func (x *LeaveRequest) Reset() { *x = LeaveRequest{} - mi := &file_round_proto_msgTypes[16] + mi := &file_round_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1393,7 +1533,7 @@ func (x *LeaveRequest) String() string { func (*LeaveRequest) ProtoMessage() {} func (x *LeaveRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[16] + mi := &file_round_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1406,7 +1546,7 @@ func (x *LeaveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LeaveRequest.ProtoReflect.Descriptor instead. func (*LeaveRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{16} + return file_round_proto_rawDescGZIP(), []int{17} } func (x *LeaveRequest) GetPkScript() []byte { @@ -1448,7 +1588,7 @@ type JoinRoundAuth struct { func (x *JoinRoundAuth) Reset() { *x = JoinRoundAuth{} - mi := &file_round_proto_msgTypes[17] + mi := &file_round_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1460,7 +1600,7 @@ func (x *JoinRoundAuth) String() string { func (*JoinRoundAuth) ProtoMessage() {} func (x *JoinRoundAuth) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[17] + mi := &file_round_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1473,7 +1613,7 @@ func (x *JoinRoundAuth) ProtoReflect() protoreflect.Message { // Deprecated: Use JoinRoundAuth.ProtoReflect.Descriptor instead. func (*JoinRoundAuth) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{17} + return file_round_proto_rawDescGZIP(), []int{18} } func (x *JoinRoundAuth) GetMessage() []byte { @@ -1536,7 +1676,7 @@ type JoinRoundRequest struct { func (x *JoinRoundRequest) Reset() { *x = JoinRoundRequest{} - mi := &file_round_proto_msgTypes[18] + mi := &file_round_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1548,7 +1688,7 @@ func (x *JoinRoundRequest) String() string { func (*JoinRoundRequest) ProtoMessage() {} func (x *JoinRoundRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[18] + mi := &file_round_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1561,7 +1701,7 @@ func (x *JoinRoundRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use JoinRoundRequest.ProtoReflect.Descriptor instead. func (*JoinRoundRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{18} + return file_round_proto_rawDescGZIP(), []int{19} } func (x *JoinRoundRequest) GetIdentifier() []byte { @@ -1639,7 +1779,7 @@ type FeeBreakdown struct { func (x *FeeBreakdown) Reset() { *x = FeeBreakdown{} - mi := &file_round_proto_msgTypes[19] + mi := &file_round_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1651,7 +1791,7 @@ func (x *FeeBreakdown) String() string { func (*FeeBreakdown) ProtoMessage() {} func (x *FeeBreakdown) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[19] + mi := &file_round_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1664,7 +1804,7 @@ func (x *FeeBreakdown) ProtoReflect() protoreflect.Message { // Deprecated: Use FeeBreakdown.ProtoReflect.Descriptor instead. func (*FeeBreakdown) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{19} + return file_round_proto_rawDescGZIP(), []int{20} } func (x *FeeBreakdown) GetChainFeeSat() int64 { @@ -1725,7 +1865,7 @@ type VTXOQuote struct { func (x *VTXOQuote) Reset() { *x = VTXOQuote{} - mi := &file_round_proto_msgTypes[20] + mi := &file_round_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1737,7 +1877,7 @@ func (x *VTXOQuote) String() string { func (*VTXOQuote) ProtoMessage() {} func (x *VTXOQuote) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[20] + mi := &file_round_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1750,7 +1890,7 @@ func (x *VTXOQuote) ProtoReflect() protoreflect.Message { // Deprecated: Use VTXOQuote.ProtoReflect.Descriptor instead. func (*VTXOQuote) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{20} + return file_round_proto_rawDescGZIP(), []int{21} } func (x *VTXOQuote) GetPkScript() []byte { @@ -1790,7 +1930,7 @@ type LeaveQuote struct { func (x *LeaveQuote) Reset() { *x = LeaveQuote{} - mi := &file_round_proto_msgTypes[21] + mi := &file_round_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1802,7 +1942,7 @@ func (x *LeaveQuote) String() string { func (*LeaveQuote) ProtoMessage() {} func (x *LeaveQuote) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[21] + mi := &file_round_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1815,7 +1955,7 @@ func (x *LeaveQuote) ProtoReflect() protoreflect.Message { // Deprecated: Use LeaveQuote.ProtoReflect.Descriptor instead. func (*LeaveQuote) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{21} + return file_round_proto_rawDescGZIP(), []int{22} } func (x *LeaveQuote) GetPkScript() []byte { @@ -1879,7 +2019,7 @@ type JoinRoundQuote struct { func (x *JoinRoundQuote) Reset() { *x = JoinRoundQuote{} - mi := &file_round_proto_msgTypes[22] + mi := &file_round_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1891,7 +2031,7 @@ func (x *JoinRoundQuote) String() string { func (*JoinRoundQuote) ProtoMessage() {} func (x *JoinRoundQuote) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[22] + mi := &file_round_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1904,7 +2044,7 @@ func (x *JoinRoundQuote) ProtoReflect() protoreflect.Message { // Deprecated: Use JoinRoundQuote.ProtoReflect.Descriptor instead. func (*JoinRoundQuote) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{22} + return file_round_proto_rawDescGZIP(), []int{23} } func (x *JoinRoundQuote) GetRoundId() string { @@ -1990,7 +2130,7 @@ type JoinRoundAccept struct { func (x *JoinRoundAccept) Reset() { *x = JoinRoundAccept{} - mi := &file_round_proto_msgTypes[23] + mi := &file_round_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2002,7 +2142,7 @@ func (x *JoinRoundAccept) String() string { func (*JoinRoundAccept) ProtoMessage() {} func (x *JoinRoundAccept) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[23] + mi := &file_round_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2015,7 +2155,7 @@ func (x *JoinRoundAccept) ProtoReflect() protoreflect.Message { // Deprecated: Use JoinRoundAccept.ProtoReflect.Descriptor instead. func (*JoinRoundAccept) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{23} + return file_round_proto_rawDescGZIP(), []int{24} } func (x *JoinRoundAccept) GetRoundId() string { @@ -2052,7 +2192,7 @@ type JoinRoundReject struct { func (x *JoinRoundReject) Reset() { *x = JoinRoundReject{} - mi := &file_round_proto_msgTypes[24] + mi := &file_round_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2064,7 +2204,7 @@ func (x *JoinRoundReject) String() string { func (*JoinRoundReject) ProtoMessage() {} func (x *JoinRoundReject) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[24] + mi := &file_round_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2077,7 +2217,7 @@ func (x *JoinRoundReject) ProtoReflect() protoreflect.Message { // Deprecated: Use JoinRoundReject.ProtoReflect.Descriptor instead. func (*JoinRoundReject) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{24} + return file_round_proto_rawDescGZIP(), []int{25} } func (x *JoinRoundReject) GetRoundId() string { @@ -2116,7 +2256,7 @@ type SubmitNoncesRequest struct { func (x *SubmitNoncesRequest) Reset() { *x = SubmitNoncesRequest{} - mi := &file_round_proto_msgTypes[25] + mi := &file_round_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2128,7 +2268,7 @@ func (x *SubmitNoncesRequest) String() string { func (*SubmitNoncesRequest) ProtoMessage() {} func (x *SubmitNoncesRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[25] + mi := &file_round_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2141,7 +2281,7 @@ func (x *SubmitNoncesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitNoncesRequest.ProtoReflect.Descriptor instead. func (*SubmitNoncesRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{25} + return file_round_proto_rawDescGZIP(), []int{26} } func (x *SubmitNoncesRequest) GetRoundId() []byte { @@ -2170,7 +2310,7 @@ type SignerNonces struct { func (x *SignerNonces) Reset() { *x = SignerNonces{} - mi := &file_round_proto_msgTypes[26] + mi := &file_round_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2182,7 +2322,7 @@ func (x *SignerNonces) String() string { func (*SignerNonces) ProtoMessage() {} func (x *SignerNonces) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[26] + mi := &file_round_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2195,7 +2335,7 @@ func (x *SignerNonces) ProtoReflect() protoreflect.Message { // Deprecated: Use SignerNonces.ProtoReflect.Descriptor instead. func (*SignerNonces) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{26} + return file_round_proto_rawDescGZIP(), []int{27} } func (x *SignerNonces) GetTxNonces() map[string][]byte { @@ -2220,7 +2360,7 @@ type SubmitPartialSigRequest struct { func (x *SubmitPartialSigRequest) Reset() { *x = SubmitPartialSigRequest{} - mi := &file_round_proto_msgTypes[27] + mi := &file_round_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2232,7 +2372,7 @@ func (x *SubmitPartialSigRequest) String() string { func (*SubmitPartialSigRequest) ProtoMessage() {} func (x *SubmitPartialSigRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[27] + mi := &file_round_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2245,7 +2385,7 @@ func (x *SubmitPartialSigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPartialSigRequest.ProtoReflect.Descriptor instead. func (*SubmitPartialSigRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{27} + return file_round_proto_rawDescGZIP(), []int{28} } func (x *SubmitPartialSigRequest) GetRoundId() []byte { @@ -2275,7 +2415,7 @@ type SignerPartialSigs struct { func (x *SignerPartialSigs) Reset() { *x = SignerPartialSigs{} - mi := &file_round_proto_msgTypes[28] + mi := &file_round_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2287,7 +2427,7 @@ func (x *SignerPartialSigs) String() string { func (*SignerPartialSigs) ProtoMessage() {} func (x *SignerPartialSigs) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[28] + mi := &file_round_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2300,7 +2440,7 @@ func (x *SignerPartialSigs) ProtoReflect() protoreflect.Message { // Deprecated: Use SignerPartialSigs.ProtoReflect.Descriptor instead. func (*SignerPartialSigs) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{28} + return file_round_proto_rawDescGZIP(), []int{29} } func (x *SignerPartialSigs) GetTxSigs() map[string][]byte { @@ -2325,7 +2465,7 @@ type BoardingInputSignature struct { func (x *BoardingInputSignature) Reset() { *x = BoardingInputSignature{} - mi := &file_round_proto_msgTypes[29] + mi := &file_round_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2337,7 +2477,7 @@ func (x *BoardingInputSignature) String() string { func (*BoardingInputSignature) ProtoMessage() {} func (x *BoardingInputSignature) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[29] + mi := &file_round_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2350,7 +2490,7 @@ func (x *BoardingInputSignature) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardingInputSignature.ProtoReflect.Descriptor instead. func (*BoardingInputSignature) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{29} + return file_round_proto_rawDescGZIP(), []int{30} } func (x *BoardingInputSignature) GetInputIndex() int32 { @@ -2388,7 +2528,7 @@ type SubmitForfeitSigRequest struct { func (x *SubmitForfeitSigRequest) Reset() { *x = SubmitForfeitSigRequest{} - mi := &file_round_proto_msgTypes[30] + mi := &file_round_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2400,7 +2540,7 @@ func (x *SubmitForfeitSigRequest) String() string { func (*SubmitForfeitSigRequest) ProtoMessage() {} func (x *SubmitForfeitSigRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[30] + mi := &file_round_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2413,7 +2553,7 @@ func (x *SubmitForfeitSigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitForfeitSigRequest.ProtoReflect.Descriptor instead. func (*SubmitForfeitSigRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{30} + return file_round_proto_rawDescGZIP(), []int{31} } func (x *SubmitForfeitSigRequest) GetRoundId() []byte { @@ -2444,7 +2584,7 @@ type ForfeitParticipantSig struct { func (x *ForfeitParticipantSig) Reset() { *x = ForfeitParticipantSig{} - mi := &file_round_proto_msgTypes[31] + mi := &file_round_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2456,7 +2596,7 @@ func (x *ForfeitParticipantSig) String() string { func (*ForfeitParticipantSig) ProtoMessage() {} func (x *ForfeitParticipantSig) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[31] + mi := &file_round_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2469,7 +2609,7 @@ func (x *ForfeitParticipantSig) ProtoReflect() protoreflect.Message { // Deprecated: Use ForfeitParticipantSig.ProtoReflect.Descriptor instead. func (*ForfeitParticipantSig) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{31} + return file_round_proto_rawDescGZIP(), []int{32} } func (x *ForfeitParticipantSig) GetPubkey() []byte { @@ -2512,7 +2652,7 @@ type ForfeitTxSig struct { func (x *ForfeitTxSig) Reset() { *x = ForfeitTxSig{} - mi := &file_round_proto_msgTypes[32] + mi := &file_round_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2524,7 +2664,7 @@ func (x *ForfeitTxSig) String() string { func (*ForfeitTxSig) ProtoMessage() {} func (x *ForfeitTxSig) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[32] + mi := &file_round_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2537,7 +2677,7 @@ func (x *ForfeitTxSig) ProtoReflect() protoreflect.Message { // Deprecated: Use ForfeitTxSig.ProtoReflect.Descriptor instead. func (*ForfeitTxSig) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{32} + return file_round_proto_rawDescGZIP(), []int{33} } func (x *ForfeitTxSig) GetVtxoOutpoint() *Outpoint { @@ -2589,7 +2729,7 @@ type SubmitVTXOForfeitSigsRequest struct { func (x *SubmitVTXOForfeitSigsRequest) Reset() { *x = SubmitVTXOForfeitSigsRequest{} - mi := &file_round_proto_msgTypes[33] + mi := &file_round_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2601,7 +2741,7 @@ func (x *SubmitVTXOForfeitSigsRequest) String() string { func (*SubmitVTXOForfeitSigsRequest) ProtoMessage() {} func (x *SubmitVTXOForfeitSigsRequest) ProtoReflect() protoreflect.Message { - mi := &file_round_proto_msgTypes[33] + mi := &file_round_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2614,7 +2754,7 @@ func (x *SubmitVTXOForfeitSigsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitVTXOForfeitSigsRequest.ProtoReflect.Descriptor instead. func (*SubmitVTXOForfeitSigsRequest) Descriptor() ([]byte, []int) { - return file_round_proto_rawDescGZIP(), []int{33} + return file_round_proto_rawDescGZIP(), []int{34} } func (x *SubmitVTXOForfeitSigsRequest) GetRoundId() []byte { @@ -2631,6 +2771,57 @@ func (x *SubmitVTXOForfeitSigsRequest) GetForfeitTxs() []*ForfeitTxSig { return nil } +// QueryRoundStatusRequest asks the operator for the authoritative +// lifecycle status of a round. A client that has sent forfeit +// signatures uses it to reconcile a round's fate before releasing the +// forfeit reservations: only a ROUND_STATUS_DEAD answer (the round +// never finalized, so its commitment tx can never confirm) makes the +// release double-spend-safe. +type QueryRoundStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // round_id is the UUID of the round (16 bytes). + RoundId []byte `protobuf:"bytes,1,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryRoundStatusRequest) Reset() { + *x = QueryRoundStatusRequest{} + mi := &file_round_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryRoundStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryRoundStatusRequest) ProtoMessage() {} + +func (x *QueryRoundStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_round_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryRoundStatusRequest.ProtoReflect.Descriptor instead. +func (*QueryRoundStatusRequest) Descriptor() ([]byte, []int) { + return file_round_proto_rawDescGZIP(), []int{35} +} + +func (x *QueryRoundStatusRequest) GetRoundId() []byte { + if x != nil { + return x.RoundId + } + return nil +} + var File_round_proto protoreflect.FileDescriptor const file_round_proto_rawDesc = "" + @@ -2721,7 +2912,11 @@ const file_round_proto_rawDesc = "" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12=\n" + "\ffailure_code\x18\x03 \x01(\x0e2\x1a.round.v1.RoundFailureCodeR\vfailureCode\".\n" + "\x0fClientErrorResp\x12\x1b\n" + - "\terror_msg\x18\x01 \x01(\tR\berrorMsg\"\x85\x01\n" + + "\terror_msg\x18\x01 \x01(\tR\berrorMsg\"\x84\x01\n" + + "\x17ClientRoundStatusReport\x12\x19\n" + + "\bround_id\x18\x01 \x01(\fR\aroundId\x126\n" + + "\x06status\x18\x02 \x01(\x0e2\x1e.round.v1.RoundLifecycleStatusR\x06status\x12\x16\n" + + "\x06detail\x18\x03 \x01(\tR\x06detail\"\x85\x01\n" + "\x0fBoardingRequest\x12.\n" + "\boutpoint\x18\x01 \x01(\v2\x12.round.v1.OutpointR\boutpoint\x12'\n" + "\x0fpolicy_template\x18\x02 \x01(\fR\x0epolicyTemplate\x12\x19\n" + @@ -2841,14 +3036,22 @@ const file_round_proto_rawDesc = "" + "\x1cSubmitVTXOForfeitSigsRequest\x12\x19\n" + "\bround_id\x18\x01 \x01(\fR\aroundId\x127\n" + "\vforfeit_txs\x18\x02 \x03(\v2\x16.round.v1.ForfeitTxSigR\n" + - "forfeitTxs*\\\n" + + "forfeitTxs\"4\n" + + "\x17QueryRoundStatusRequest\x12\x19\n" + + "\bround_id\x18\x01 \x01(\fR\aroundId*\\\n" + "\x10RoundFailureCode\x12\x19\n" + "\x15ROUND_FAILURE_UNKNOWN\x10\x00\x12-\n" + - ")ROUND_FAILURE_INSUFFICIENT_OPERATOR_FUNDS\x10\x01*V\n" + + ")ROUND_FAILURE_INSUFFICIENT_OPERATOR_FUNDS\x10\x01*\x9f\x01\n" + + "\x14RoundLifecycleStatus\x12\x1c\n" + + "\x18ROUND_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16ROUND_STATUS_IN_FLIGHT\x10\x01\x12\x1a\n" + + "\x16ROUND_STATUS_BROADCAST\x10\x02\x12\x1a\n" + + "\x16ROUND_STATUS_CONFIRMED\x10\x03\x12\x15\n" + + "\x11ROUND_STATUS_DEAD\x10\x04*V\n" + "\vQuoteReason\x12\f\n" + "\bQUOTE_OK\x10\x00\x12\x19\n" + "\x15INSUFFICIENT_RESIDUAL\x10\x01\x12\x1e\n" + - "\x1aINVALID_CHANGE_DESIGNATION\x10\x022\xc2\x04\n" + + "\x1aINVALID_CHANGE_DESIGNATION\x10\x022\x96\x05\n" + "\fRoundService\x12D\n" + "\tJoinRound\x12\x1a.round.v1.JoinRoundRequest\x1a\x1b.round.v1.ClientSuccessResp\x12E\n" + "\vAcceptQuote\x12\x19.round.v1.JoinRoundAccept\x1a\x1b.round.v1.ClientSuccessResp\x12E\n" + @@ -2856,7 +3059,8 @@ const file_round_proto_rawDesc = "" + "\fSubmitNonces\x12\x1d.round.v1.SubmitNoncesRequest\x1a\x1d.round.v1.ClientVTXOAggNonces\x12S\n" + "\x11SubmitPartialSigs\x12!.round.v1.SubmitPartialSigRequest\x1a\x1b.round.v1.ClientVTXOAggSigs\x12]\n" + "\x11SubmitForfeitSigs\x12!.round.v1.SubmitForfeitSigRequest\x1a%.round.v1.ClientAwaitingInputSigsResp\x12\\\n" + - "\x15SubmitVTXOForfeitSigs\x12&.round.v1.SubmitVTXOForfeitSigsRequest\x1a\x1b.round.v1.ClientSuccessRespB9Z7github.com/lightninglabs/wavelength/rpc/roundpb;roundpbb\x06proto3" + "\x15SubmitVTXOForfeitSigs\x12&.round.v1.SubmitVTXOForfeitSigsRequest\x1a\x1b.round.v1.ClientSuccessResp\x12R\n" + + "\x10QueryRoundStatus\x12!.round.v1.QueryRoundStatusRequest\x1a\x1b.round.v1.ClientSuccessRespB9Z7github.com/lightninglabs/wavelength/rpc/roundpb;roundpbb\x06proto3" var ( file_round_proto_rawDescOnce sync.Once @@ -2870,115 +3074,121 @@ func file_round_proto_rawDescGZIP() []byte { return file_round_proto_rawDescData } -var file_round_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_round_proto_msgTypes = make([]protoimpl.MessageInfo, 43) +var file_round_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_round_proto_msgTypes = make([]protoimpl.MessageInfo, 45) var file_round_proto_goTypes = []any{ (RoundFailureCode)(0), // 0: round.v1.RoundFailureCode - (QuoteReason)(0), // 1: round.v1.QuoteReason - (*Outpoint)(nil), // 2: round.v1.Outpoint - (*TxOut)(nil), // 3: round.v1.TxOut - (*TreeNode)(nil), // 4: round.v1.TreeNode - (*VTXOTree)(nil), // 5: round.v1.VTXOTree - (*ConnectorLeafInfo)(nil), // 6: round.v1.ConnectorLeafInfo - (*ClientConnectorLeafInfo)(nil), // 7: round.v1.ClientConnectorLeafInfo - (*ClientSuccessResp)(nil), // 8: round.v1.ClientSuccessResp - (*ClientBatchInfo)(nil), // 9: round.v1.ClientBatchInfo - (*ClientAwaitingInputSigsResp)(nil), // 10: round.v1.ClientAwaitingInputSigsResp - (*ClientVTXOAggNonces)(nil), // 11: round.v1.ClientVTXOAggNonces - (*ClientVTXOAggSigs)(nil), // 12: round.v1.ClientVTXOAggSigs - (*ClientRoundFailedResp)(nil), // 13: round.v1.ClientRoundFailedResp - (*ClientErrorResp)(nil), // 14: round.v1.ClientErrorResp - (*BoardingRequest)(nil), // 15: round.v1.BoardingRequest - (*VTXORequest)(nil), // 16: round.v1.VTXORequest - (*ForfeitRequest)(nil), // 17: round.v1.ForfeitRequest - (*LeaveRequest)(nil), // 18: round.v1.LeaveRequest - (*JoinRoundAuth)(nil), // 19: round.v1.JoinRoundAuth - (*JoinRoundRequest)(nil), // 20: round.v1.JoinRoundRequest - (*FeeBreakdown)(nil), // 21: round.v1.FeeBreakdown - (*VTXOQuote)(nil), // 22: round.v1.VTXOQuote - (*LeaveQuote)(nil), // 23: round.v1.LeaveQuote - (*JoinRoundQuote)(nil), // 24: round.v1.JoinRoundQuote - (*JoinRoundAccept)(nil), // 25: round.v1.JoinRoundAccept - (*JoinRoundReject)(nil), // 26: round.v1.JoinRoundReject - (*SubmitNoncesRequest)(nil), // 27: round.v1.SubmitNoncesRequest - (*SignerNonces)(nil), // 28: round.v1.SignerNonces - (*SubmitPartialSigRequest)(nil), // 29: round.v1.SubmitPartialSigRequest - (*SignerPartialSigs)(nil), // 30: round.v1.SignerPartialSigs - (*BoardingInputSignature)(nil), // 31: round.v1.BoardingInputSignature - (*SubmitForfeitSigRequest)(nil), // 32: round.v1.SubmitForfeitSigRequest - (*ForfeitParticipantSig)(nil), // 33: round.v1.ForfeitParticipantSig - (*ForfeitTxSig)(nil), // 34: round.v1.ForfeitTxSig - (*SubmitVTXOForfeitSigsRequest)(nil), // 35: round.v1.SubmitVTXOForfeitSigsRequest - nil, // 36: round.v1.TreeNode.ChildrenEntry - nil, // 37: round.v1.ClientBatchInfo.VtxoTreePathsEntry - nil, // 38: round.v1.ClientBatchInfo.ConnectorLeafMapEntry - nil, // 39: round.v1.ClientVTXOAggNonces.AggNoncesEntry - nil, // 40: round.v1.ClientVTXOAggSigs.AggSigsEntry - nil, // 41: round.v1.SubmitNoncesRequest.NoncesEntry - nil, // 42: round.v1.SignerNonces.TxNoncesEntry - nil, // 43: round.v1.SubmitPartialSigRequest.SignaturesEntry - nil, // 44: round.v1.SignerPartialSigs.TxSigsEntry + (RoundLifecycleStatus)(0), // 1: round.v1.RoundLifecycleStatus + (QuoteReason)(0), // 2: round.v1.QuoteReason + (*Outpoint)(nil), // 3: round.v1.Outpoint + (*TxOut)(nil), // 4: round.v1.TxOut + (*TreeNode)(nil), // 5: round.v1.TreeNode + (*VTXOTree)(nil), // 6: round.v1.VTXOTree + (*ConnectorLeafInfo)(nil), // 7: round.v1.ConnectorLeafInfo + (*ClientConnectorLeafInfo)(nil), // 8: round.v1.ClientConnectorLeafInfo + (*ClientSuccessResp)(nil), // 9: round.v1.ClientSuccessResp + (*ClientBatchInfo)(nil), // 10: round.v1.ClientBatchInfo + (*ClientAwaitingInputSigsResp)(nil), // 11: round.v1.ClientAwaitingInputSigsResp + (*ClientVTXOAggNonces)(nil), // 12: round.v1.ClientVTXOAggNonces + (*ClientVTXOAggSigs)(nil), // 13: round.v1.ClientVTXOAggSigs + (*ClientRoundFailedResp)(nil), // 14: round.v1.ClientRoundFailedResp + (*ClientErrorResp)(nil), // 15: round.v1.ClientErrorResp + (*ClientRoundStatusReport)(nil), // 16: round.v1.ClientRoundStatusReport + (*BoardingRequest)(nil), // 17: round.v1.BoardingRequest + (*VTXORequest)(nil), // 18: round.v1.VTXORequest + (*ForfeitRequest)(nil), // 19: round.v1.ForfeitRequest + (*LeaveRequest)(nil), // 20: round.v1.LeaveRequest + (*JoinRoundAuth)(nil), // 21: round.v1.JoinRoundAuth + (*JoinRoundRequest)(nil), // 22: round.v1.JoinRoundRequest + (*FeeBreakdown)(nil), // 23: round.v1.FeeBreakdown + (*VTXOQuote)(nil), // 24: round.v1.VTXOQuote + (*LeaveQuote)(nil), // 25: round.v1.LeaveQuote + (*JoinRoundQuote)(nil), // 26: round.v1.JoinRoundQuote + (*JoinRoundAccept)(nil), // 27: round.v1.JoinRoundAccept + (*JoinRoundReject)(nil), // 28: round.v1.JoinRoundReject + (*SubmitNoncesRequest)(nil), // 29: round.v1.SubmitNoncesRequest + (*SignerNonces)(nil), // 30: round.v1.SignerNonces + (*SubmitPartialSigRequest)(nil), // 31: round.v1.SubmitPartialSigRequest + (*SignerPartialSigs)(nil), // 32: round.v1.SignerPartialSigs + (*BoardingInputSignature)(nil), // 33: round.v1.BoardingInputSignature + (*SubmitForfeitSigRequest)(nil), // 34: round.v1.SubmitForfeitSigRequest + (*ForfeitParticipantSig)(nil), // 35: round.v1.ForfeitParticipantSig + (*ForfeitTxSig)(nil), // 36: round.v1.ForfeitTxSig + (*SubmitVTXOForfeitSigsRequest)(nil), // 37: round.v1.SubmitVTXOForfeitSigsRequest + (*QueryRoundStatusRequest)(nil), // 38: round.v1.QueryRoundStatusRequest + nil, // 39: round.v1.TreeNode.ChildrenEntry + nil, // 40: round.v1.ClientBatchInfo.VtxoTreePathsEntry + nil, // 41: round.v1.ClientBatchInfo.ConnectorLeafMapEntry + nil, // 42: round.v1.ClientVTXOAggNonces.AggNoncesEntry + nil, // 43: round.v1.ClientVTXOAggSigs.AggSigsEntry + nil, // 44: round.v1.SubmitNoncesRequest.NoncesEntry + nil, // 45: round.v1.SignerNonces.TxNoncesEntry + nil, // 46: round.v1.SubmitPartialSigRequest.SignaturesEntry + nil, // 47: round.v1.SignerPartialSigs.TxSigsEntry } var file_round_proto_depIdxs = []int32{ - 2, // 0: round.v1.TreeNode.input:type_name -> round.v1.Outpoint - 3, // 1: round.v1.TreeNode.outputs:type_name -> round.v1.TxOut - 36, // 2: round.v1.TreeNode.children:type_name -> round.v1.TreeNode.ChildrenEntry - 4, // 3: round.v1.VTXOTree.nodes:type_name -> round.v1.TreeNode - 2, // 4: round.v1.VTXOTree.batch_outpoint:type_name -> round.v1.Outpoint - 3, // 5: round.v1.VTXOTree.batch_output:type_name -> round.v1.TxOut - 2, // 6: round.v1.ConnectorLeafInfo.leaf_outpoint:type_name -> round.v1.Outpoint - 3, // 7: round.v1.ConnectorLeafInfo.leaf_output:type_name -> round.v1.TxOut - 2, // 8: round.v1.ClientConnectorLeafInfo.connector_outpoint:type_name -> round.v1.Outpoint - 2, // 9: round.v1.ClientSuccessResp.accepted_boarding_outpoints:type_name -> round.v1.Outpoint - 2, // 10: round.v1.ClientSuccessResp.accepted_vtxo_outpoints:type_name -> round.v1.Outpoint - 37, // 11: round.v1.ClientBatchInfo.vtxo_tree_paths:type_name -> round.v1.ClientBatchInfo.VtxoTreePathsEntry - 38, // 12: round.v1.ClientBatchInfo.connector_leaf_map:type_name -> round.v1.ClientBatchInfo.ConnectorLeafMapEntry - 39, // 13: round.v1.ClientVTXOAggNonces.agg_nonces:type_name -> round.v1.ClientVTXOAggNonces.AggNoncesEntry - 40, // 14: round.v1.ClientVTXOAggSigs.agg_sigs:type_name -> round.v1.ClientVTXOAggSigs.AggSigsEntry + 3, // 0: round.v1.TreeNode.input:type_name -> round.v1.Outpoint + 4, // 1: round.v1.TreeNode.outputs:type_name -> round.v1.TxOut + 39, // 2: round.v1.TreeNode.children:type_name -> round.v1.TreeNode.ChildrenEntry + 5, // 3: round.v1.VTXOTree.nodes:type_name -> round.v1.TreeNode + 3, // 4: round.v1.VTXOTree.batch_outpoint:type_name -> round.v1.Outpoint + 4, // 5: round.v1.VTXOTree.batch_output:type_name -> round.v1.TxOut + 3, // 6: round.v1.ConnectorLeafInfo.leaf_outpoint:type_name -> round.v1.Outpoint + 4, // 7: round.v1.ConnectorLeafInfo.leaf_output:type_name -> round.v1.TxOut + 3, // 8: round.v1.ClientConnectorLeafInfo.connector_outpoint:type_name -> round.v1.Outpoint + 3, // 9: round.v1.ClientSuccessResp.accepted_boarding_outpoints:type_name -> round.v1.Outpoint + 3, // 10: round.v1.ClientSuccessResp.accepted_vtxo_outpoints:type_name -> round.v1.Outpoint + 40, // 11: round.v1.ClientBatchInfo.vtxo_tree_paths:type_name -> round.v1.ClientBatchInfo.VtxoTreePathsEntry + 41, // 12: round.v1.ClientBatchInfo.connector_leaf_map:type_name -> round.v1.ClientBatchInfo.ConnectorLeafMapEntry + 42, // 13: round.v1.ClientVTXOAggNonces.agg_nonces:type_name -> round.v1.ClientVTXOAggNonces.AggNoncesEntry + 43, // 14: round.v1.ClientVTXOAggSigs.agg_sigs:type_name -> round.v1.ClientVTXOAggSigs.AggSigsEntry 0, // 15: round.v1.ClientRoundFailedResp.failure_code:type_name -> round.v1.RoundFailureCode - 2, // 16: round.v1.BoardingRequest.outpoint:type_name -> round.v1.Outpoint - 2, // 17: round.v1.ForfeitRequest.vtxo_outpoint:type_name -> round.v1.Outpoint - 15, // 18: round.v1.JoinRoundRequest.boarding_requests:type_name -> round.v1.BoardingRequest - 16, // 19: round.v1.JoinRoundRequest.vtxo_requests:type_name -> round.v1.VTXORequest - 17, // 20: round.v1.JoinRoundRequest.forfeit_requests:type_name -> round.v1.ForfeitRequest - 18, // 21: round.v1.JoinRoundRequest.leave_requests:type_name -> round.v1.LeaveRequest - 19, // 22: round.v1.JoinRoundRequest.auth:type_name -> round.v1.JoinRoundAuth - 22, // 23: round.v1.JoinRoundQuote.vtxo_quotes:type_name -> round.v1.VTXOQuote - 23, // 24: round.v1.JoinRoundQuote.leave_quotes:type_name -> round.v1.LeaveQuote - 21, // 25: round.v1.JoinRoundQuote.breakdown:type_name -> round.v1.FeeBreakdown - 1, // 26: round.v1.JoinRoundQuote.reject_reason:type_name -> round.v1.QuoteReason - 41, // 27: round.v1.SubmitNoncesRequest.nonces:type_name -> round.v1.SubmitNoncesRequest.NoncesEntry - 42, // 28: round.v1.SignerNonces.tx_nonces:type_name -> round.v1.SignerNonces.TxNoncesEntry - 43, // 29: round.v1.SubmitPartialSigRequest.signatures:type_name -> round.v1.SubmitPartialSigRequest.SignaturesEntry - 44, // 30: round.v1.SignerPartialSigs.tx_sigs:type_name -> round.v1.SignerPartialSigs.TxSigsEntry - 2, // 31: round.v1.BoardingInputSignature.outpoint:type_name -> round.v1.Outpoint - 31, // 32: round.v1.SubmitForfeitSigRequest.signatures:type_name -> round.v1.BoardingInputSignature - 2, // 33: round.v1.ForfeitTxSig.vtxo_outpoint:type_name -> round.v1.Outpoint - 33, // 34: round.v1.ForfeitTxSig.participant_sigs:type_name -> round.v1.ForfeitParticipantSig - 34, // 35: round.v1.SubmitVTXOForfeitSigsRequest.forfeit_txs:type_name -> round.v1.ForfeitTxSig - 5, // 36: round.v1.ClientBatchInfo.VtxoTreePathsEntry.value:type_name -> round.v1.VTXOTree - 6, // 37: round.v1.ClientBatchInfo.ConnectorLeafMapEntry.value:type_name -> round.v1.ConnectorLeafInfo - 28, // 38: round.v1.SubmitNoncesRequest.NoncesEntry.value:type_name -> round.v1.SignerNonces - 30, // 39: round.v1.SubmitPartialSigRequest.SignaturesEntry.value:type_name -> round.v1.SignerPartialSigs - 20, // 40: round.v1.RoundService.JoinRound:input_type -> round.v1.JoinRoundRequest - 25, // 41: round.v1.RoundService.AcceptQuote:input_type -> round.v1.JoinRoundAccept - 26, // 42: round.v1.RoundService.RejectQuote:input_type -> round.v1.JoinRoundReject - 27, // 43: round.v1.RoundService.SubmitNonces:input_type -> round.v1.SubmitNoncesRequest - 29, // 44: round.v1.RoundService.SubmitPartialSigs:input_type -> round.v1.SubmitPartialSigRequest - 32, // 45: round.v1.RoundService.SubmitForfeitSigs:input_type -> round.v1.SubmitForfeitSigRequest - 35, // 46: round.v1.RoundService.SubmitVTXOForfeitSigs:input_type -> round.v1.SubmitVTXOForfeitSigsRequest - 8, // 47: round.v1.RoundService.JoinRound:output_type -> round.v1.ClientSuccessResp - 8, // 48: round.v1.RoundService.AcceptQuote:output_type -> round.v1.ClientSuccessResp - 8, // 49: round.v1.RoundService.RejectQuote:output_type -> round.v1.ClientSuccessResp - 11, // 50: round.v1.RoundService.SubmitNonces:output_type -> round.v1.ClientVTXOAggNonces - 12, // 51: round.v1.RoundService.SubmitPartialSigs:output_type -> round.v1.ClientVTXOAggSigs - 10, // 52: round.v1.RoundService.SubmitForfeitSigs:output_type -> round.v1.ClientAwaitingInputSigsResp - 8, // 53: round.v1.RoundService.SubmitVTXOForfeitSigs:output_type -> round.v1.ClientSuccessResp - 47, // [47:54] is the sub-list for method output_type - 40, // [40:47] is the sub-list for method input_type - 40, // [40:40] is the sub-list for extension type_name - 40, // [40:40] is the sub-list for extension extendee - 0, // [0:40] is the sub-list for field type_name + 1, // 16: round.v1.ClientRoundStatusReport.status:type_name -> round.v1.RoundLifecycleStatus + 3, // 17: round.v1.BoardingRequest.outpoint:type_name -> round.v1.Outpoint + 3, // 18: round.v1.ForfeitRequest.vtxo_outpoint:type_name -> round.v1.Outpoint + 17, // 19: round.v1.JoinRoundRequest.boarding_requests:type_name -> round.v1.BoardingRequest + 18, // 20: round.v1.JoinRoundRequest.vtxo_requests:type_name -> round.v1.VTXORequest + 19, // 21: round.v1.JoinRoundRequest.forfeit_requests:type_name -> round.v1.ForfeitRequest + 20, // 22: round.v1.JoinRoundRequest.leave_requests:type_name -> round.v1.LeaveRequest + 21, // 23: round.v1.JoinRoundRequest.auth:type_name -> round.v1.JoinRoundAuth + 24, // 24: round.v1.JoinRoundQuote.vtxo_quotes:type_name -> round.v1.VTXOQuote + 25, // 25: round.v1.JoinRoundQuote.leave_quotes:type_name -> round.v1.LeaveQuote + 23, // 26: round.v1.JoinRoundQuote.breakdown:type_name -> round.v1.FeeBreakdown + 2, // 27: round.v1.JoinRoundQuote.reject_reason:type_name -> round.v1.QuoteReason + 44, // 28: round.v1.SubmitNoncesRequest.nonces:type_name -> round.v1.SubmitNoncesRequest.NoncesEntry + 45, // 29: round.v1.SignerNonces.tx_nonces:type_name -> round.v1.SignerNonces.TxNoncesEntry + 46, // 30: round.v1.SubmitPartialSigRequest.signatures:type_name -> round.v1.SubmitPartialSigRequest.SignaturesEntry + 47, // 31: round.v1.SignerPartialSigs.tx_sigs:type_name -> round.v1.SignerPartialSigs.TxSigsEntry + 3, // 32: round.v1.BoardingInputSignature.outpoint:type_name -> round.v1.Outpoint + 33, // 33: round.v1.SubmitForfeitSigRequest.signatures:type_name -> round.v1.BoardingInputSignature + 3, // 34: round.v1.ForfeitTxSig.vtxo_outpoint:type_name -> round.v1.Outpoint + 35, // 35: round.v1.ForfeitTxSig.participant_sigs:type_name -> round.v1.ForfeitParticipantSig + 36, // 36: round.v1.SubmitVTXOForfeitSigsRequest.forfeit_txs:type_name -> round.v1.ForfeitTxSig + 6, // 37: round.v1.ClientBatchInfo.VtxoTreePathsEntry.value:type_name -> round.v1.VTXOTree + 7, // 38: round.v1.ClientBatchInfo.ConnectorLeafMapEntry.value:type_name -> round.v1.ConnectorLeafInfo + 30, // 39: round.v1.SubmitNoncesRequest.NoncesEntry.value:type_name -> round.v1.SignerNonces + 32, // 40: round.v1.SubmitPartialSigRequest.SignaturesEntry.value:type_name -> round.v1.SignerPartialSigs + 22, // 41: round.v1.RoundService.JoinRound:input_type -> round.v1.JoinRoundRequest + 27, // 42: round.v1.RoundService.AcceptQuote:input_type -> round.v1.JoinRoundAccept + 28, // 43: round.v1.RoundService.RejectQuote:input_type -> round.v1.JoinRoundReject + 29, // 44: round.v1.RoundService.SubmitNonces:input_type -> round.v1.SubmitNoncesRequest + 31, // 45: round.v1.RoundService.SubmitPartialSigs:input_type -> round.v1.SubmitPartialSigRequest + 34, // 46: round.v1.RoundService.SubmitForfeitSigs:input_type -> round.v1.SubmitForfeitSigRequest + 37, // 47: round.v1.RoundService.SubmitVTXOForfeitSigs:input_type -> round.v1.SubmitVTXOForfeitSigsRequest + 38, // 48: round.v1.RoundService.QueryRoundStatus:input_type -> round.v1.QueryRoundStatusRequest + 9, // 49: round.v1.RoundService.JoinRound:output_type -> round.v1.ClientSuccessResp + 9, // 50: round.v1.RoundService.AcceptQuote:output_type -> round.v1.ClientSuccessResp + 9, // 51: round.v1.RoundService.RejectQuote:output_type -> round.v1.ClientSuccessResp + 12, // 52: round.v1.RoundService.SubmitNonces:output_type -> round.v1.ClientVTXOAggNonces + 13, // 53: round.v1.RoundService.SubmitPartialSigs:output_type -> round.v1.ClientVTXOAggSigs + 11, // 54: round.v1.RoundService.SubmitForfeitSigs:output_type -> round.v1.ClientAwaitingInputSigsResp + 9, // 55: round.v1.RoundService.SubmitVTXOForfeitSigs:output_type -> round.v1.ClientSuccessResp + 9, // 56: round.v1.RoundService.QueryRoundStatus:output_type -> round.v1.ClientSuccessResp + 49, // [49:57] is the sub-list for method output_type + 41, // [41:49] is the sub-list for method input_type + 41, // [41:41] is the sub-list for extension type_name + 41, // [41:41] is the sub-list for extension extendee + 0, // [0:41] is the sub-list for field type_name } func init() { file_round_proto_init() } @@ -2991,8 +3201,8 @@ func file_round_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_round_proto_rawDesc), len(file_round_proto_rawDesc)), - NumEnums: 2, - NumMessages: 43, + NumEnums: 3, + NumMessages: 45, NumExtensions: 0, NumServices: 1, }, diff --git a/rpc/roundpb/round.proto b/rpc/roundpb/round.proto index 5efbfadb2..7ff0e4ee1 100644 --- a/rpc/roundpb/round.proto +++ b/rpc/roundpb/round.proto @@ -284,6 +284,51 @@ message ClientErrorResp { string error_msg = 1; } +// RoundLifecycleStatus classifies where a round sits in its lifecycle +// from the operator's authoritative view: the live FSM first, then the +// durable round store. Because a finalized round is persisted atomically +// with its VTXOs before its commitment tx is ever broadcast, a round the +// operator does not know (ROUND_STATUS_DEAD) can never have produced a +// broadcastable commitment. +enum RoundLifecycleStatus { + // ROUND_STATUS_UNSPECIFIED is the zero value; treat as unknown. + ROUND_STATUS_UNSPECIFIED = 0; + + // ROUND_STATUS_IN_FLIGHT: a live FSM exists and the round has not + // reached a terminal state; the commitment tx has not been broadcast. + ROUND_STATUS_IN_FLIGHT = 1; + + // ROUND_STATUS_BROADCAST: the round finalized and its commitment tx + // has been broadcast, but has not yet confirmed. + ROUND_STATUS_BROADCAST = 2; + + // ROUND_STATUS_CONFIRMED: the round's commitment tx confirmed + // on chain. + ROUND_STATUS_CONFIRMED = 3; + + // ROUND_STATUS_DEAD: the round failed, or the operator has no record + // of it at all. Either way it never finalized, so its commitment tx + // can never confirm and any forfeit signatures the client produced + // for it are unspendable. + ROUND_STATUS_DEAD = 4; +} + +// ClientRoundStatusReport answers a QueryRoundStatusRequest with the +// operator's authoritative view of a round's lifecycle state. The +// client uses ROUND_STATUS_DEAD as the proof-of-death that makes a +// post-signing forfeit-reservation release safe. +message ClientRoundStatusReport { + // round_id is the UUID of the round (16 bytes). + bytes round_id = 1; + + // status is the round's lifecycle classification. + RoundLifecycleStatus status = 2; + + // detail is a free-form diagnostic string (e.g. the failure reason + // for a dead round). + string detail = 3; +} + // -------------------------------------------------------------------------- // Client-to-Server (C2S) request messages // -------------------------------------------------------------------------- @@ -701,6 +746,17 @@ message SubmitVTXOForfeitSigsRequest { repeated ForfeitTxSig forfeit_txs = 2; } +// QueryRoundStatusRequest asks the operator for the authoritative +// lifecycle status of a round. A client that has sent forfeit +// signatures uses it to reconcile a round's fate before releasing the +// forfeit reservations: only a ROUND_STATUS_DEAD answer (the round +// never finalized, so its commitment tx can never confirm) makes the +// release double-spend-safe. +message QueryRoundStatusRequest { + // round_id is the UUID of the round (16 bytes). + bytes round_id = 1; +} + // -------------------------------------------------------------------------- // Service definition for mailbox routing // -------------------------------------------------------------------------- @@ -740,4 +796,10 @@ service RoundService { // SubmitVTXOForfeitSigs submits VTXO forfeit signatures. rpc SubmitVTXOForfeitSigs (SubmitVTXOForfeitSigsRequest) returns (ClientSuccessResp); + + // QueryRoundStatus asks for the authoritative lifecycle status of + // a round. The answer arrives asynchronously as a + // ClientRoundStatusReport push event; the RPC ack itself carries + // no round state. + rpc QueryRoundStatus (QueryRoundStatusRequest) returns (ClientSuccessResp); } diff --git a/rpc/roundpb/round_grpc.pb.go b/rpc/roundpb/round_grpc.pb.go index 5a75ab5ff..03af1fa89 100644 --- a/rpc/roundpb/round_grpc.pb.go +++ b/rpc/roundpb/round_grpc.pb.go @@ -26,6 +26,7 @@ const ( RoundService_SubmitPartialSigs_FullMethodName = "/round.v1.RoundService/SubmitPartialSigs" RoundService_SubmitForfeitSigs_FullMethodName = "/round.v1.RoundService/SubmitForfeitSigs" RoundService_SubmitVTXOForfeitSigs_FullMethodName = "/round.v1.RoundService/SubmitVTXOForfeitSigs" + RoundService_QueryRoundStatus_FullMethodName = "/round.v1.RoundService/QueryRoundStatus" ) // RoundServiceClient is the client API for RoundService service. @@ -59,6 +60,11 @@ type RoundServiceClient interface { SubmitForfeitSigs(ctx context.Context, in *SubmitForfeitSigRequest, opts ...grpc.CallOption) (*ClientAwaitingInputSigsResp, error) // SubmitVTXOForfeitSigs submits VTXO forfeit signatures. SubmitVTXOForfeitSigs(ctx context.Context, in *SubmitVTXOForfeitSigsRequest, opts ...grpc.CallOption) (*ClientSuccessResp, error) + // QueryRoundStatus asks for the authoritative lifecycle status of + // a round. The answer arrives asynchronously as a + // ClientRoundStatusReport push event; the RPC ack itself carries + // no round state. + QueryRoundStatus(ctx context.Context, in *QueryRoundStatusRequest, opts ...grpc.CallOption) (*ClientSuccessResp, error) } type roundServiceClient struct { @@ -139,6 +145,16 @@ func (c *roundServiceClient) SubmitVTXOForfeitSigs(ctx context.Context, in *Subm return out, nil } +func (c *roundServiceClient) QueryRoundStatus(ctx context.Context, in *QueryRoundStatusRequest, opts ...grpc.CallOption) (*ClientSuccessResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClientSuccessResp) + err := c.cc.Invoke(ctx, RoundService_QueryRoundStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // RoundServiceServer is the server API for RoundService service. // All implementations must embed UnimplementedRoundServiceServer // for forward compatibility. @@ -170,6 +186,11 @@ type RoundServiceServer interface { SubmitForfeitSigs(context.Context, *SubmitForfeitSigRequest) (*ClientAwaitingInputSigsResp, error) // SubmitVTXOForfeitSigs submits VTXO forfeit signatures. SubmitVTXOForfeitSigs(context.Context, *SubmitVTXOForfeitSigsRequest) (*ClientSuccessResp, error) + // QueryRoundStatus asks for the authoritative lifecycle status of + // a round. The answer arrives asynchronously as a + // ClientRoundStatusReport push event; the RPC ack itself carries + // no round state. + QueryRoundStatus(context.Context, *QueryRoundStatusRequest) (*ClientSuccessResp, error) mustEmbedUnimplementedRoundServiceServer() } @@ -201,6 +222,9 @@ func (UnimplementedRoundServiceServer) SubmitForfeitSigs(context.Context, *Submi func (UnimplementedRoundServiceServer) SubmitVTXOForfeitSigs(context.Context, *SubmitVTXOForfeitSigsRequest) (*ClientSuccessResp, error) { return nil, status.Errorf(codes.Unimplemented, "method SubmitVTXOForfeitSigs not implemented") } +func (UnimplementedRoundServiceServer) QueryRoundStatus(context.Context, *QueryRoundStatusRequest) (*ClientSuccessResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method QueryRoundStatus not implemented") +} func (UnimplementedRoundServiceServer) mustEmbedUnimplementedRoundServiceServer() {} func (UnimplementedRoundServiceServer) testEmbeddedByValue() {} @@ -348,6 +372,24 @@ func _RoundService_SubmitVTXOForfeitSigs_Handler(srv interface{}, ctx context.Co return interceptor(ctx, in, info, handler) } +func _RoundService_QueryRoundStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRoundStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RoundServiceServer).QueryRoundStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RoundService_QueryRoundStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RoundServiceServer).QueryRoundStatus(ctx, req.(*QueryRoundStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + // RoundService_ServiceDesc is the grpc.ServiceDesc for RoundService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -383,6 +425,10 @@ var RoundService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SubmitVTXOForfeitSigs", Handler: _RoundService_SubmitVTXOForfeitSigs_Handler, }, + { + MethodName: "QueryRoundStatus", + Handler: _RoundService_QueryRoundStatus_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "round.proto", diff --git a/rpc/roundpb/round_mailboxrpc.pb.go b/rpc/roundpb/round_mailboxrpc.pb.go index 08d1d7a2a..640593872 100644 --- a/rpc/roundpb/round_mailboxrpc.pb.go +++ b/rpc/roundpb/round_mailboxrpc.pb.go @@ -38,6 +38,8 @@ type RoundServiceMailboxServer interface { SubmitForfeitSigs(ctx context.Context, req *SubmitForfeitSigRequest) (*ClientAwaitingInputSigsResp, error) // SubmitVTXOForfeitSigs handles SubmitVTXOForfeitSigs. SubmitVTXOForfeitSigs(ctx context.Context, req *SubmitVTXOForfeitSigsRequest) (*ClientSuccessResp, error) + // QueryRoundStatus handles QueryRoundStatus. + QueryRoundStatus(ctx context.Context, req *QueryRoundStatusRequest) (*ClientSuccessResp, error) } // RegisterRoundServiceMailboxServer registers handlers for RoundService. @@ -112,6 +114,16 @@ func RegisterRoundServiceMailboxServer(r rpc.Router, impl RoundServiceMailboxSer return impl.SubmitVTXOForfeitSigs(ctx, req) }) + r.Handle("round.v1.RoundService", "QueryRoundStatus", func() proto.Message { + return &QueryRoundStatusRequest{} + }, func(ctx context.Context, msg proto.Message) (proto.Message, error) { + req, ok := msg.(*QueryRoundStatusRequest) + if !ok { + return nil, fmt.Errorf("unexpected request type: %T", msg) + } + + return impl.QueryRoundStatus(ctx, req) + }) } // JoinRound calls the JoinRound RPC. @@ -274,3 +286,26 @@ func (c *RoundServiceMailboxClient) SubmitVTXOForfeitSigs(ctx context.Context, r return resp, nil } + +// QueryRoundStatus calls the QueryRoundStatus RPC. +func (c *RoundServiceMailboxClient) QueryRoundStatus(ctx context.Context, req *QueryRoundStatusRequest, opts ...rpc.RPCOptions) (*ClientSuccessResp, error) { + var opt rpc.RPCOptions + if len(opts) > 0 { + opt = opts[0] + } + + result, err := c.C.SendRPC(ctx, rpc.ServiceMethod{ + Service: "round.v1.RoundService", + Method: "QueryRoundStatus", + }, req, opt) + if err != nil { + return nil, err + } + + resp := new(ClientSuccessResp) + if err := c.C.AwaitRPC(ctx, result.CorrelationID, resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/rpc/roundpb/service.go b/rpc/roundpb/service.go index b05dd807c..d1fad2ec1 100644 --- a/rpc/roundpb/service.go +++ b/rpc/roundpb/service.go @@ -80,4 +80,16 @@ const ( // MethodSubmitVTXOForfeitSigs is the client→server method name // for SubmitVTXOForfeitSigsToServer. MethodSubmitVTXOForfeitSigs = "SubmitVTXOForfeitSigs" + + // MethodQueryRoundStatus is the client→server method name for + // QueryRoundStatusRequest. A client that has sent forfeit + // signatures uses it to reconcile a round's fate before + // releasing forfeit reservations. + MethodQueryRoundStatus = "QueryRoundStatus" + + // MethodRoundStatusReport is the push event method name for + // ClientRoundStatusReport. The server sends this in answer to + // a QueryRoundStatus, carrying the authoritative lifecycle + // status of the queried round. + MethodRoundStatusReport = "ClientRoundStatusReport" ) diff --git a/waved/server.go b/waved/server.go index 2f19c4e19..f69d58143 100644 --- a/waved/server.go +++ b/waved/server.go @@ -3384,6 +3384,20 @@ func (s *Server) registerRoundEventRoutes(router *serverconn.EventRouter) { return &round.BoardingFailed{} }, ) + + // RoundStatusReport: server answers a QueryRoundStatus probe with + // the authoritative lifecycle status of a round. InputSigSentState + // consumes it to decide whether releasing forfeit reservations is + // safe after a post-signing round failure (wavelength#844). + addRoundRoute( + roundpb.MethodRoundStatusReport, + func() proto.Message { + return &roundpb.ClientRoundStatusReport{} + }, + func() round.ClientEvent { + return &round.RoundStatusReported{} + }, + ) } // roundEventAdapt returns an Adapt closure for a round push event.