From f346cce545a970b2edb3cf9c6dc8e63f430cbae8 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 17:58:29 -0500 Subject: [PATCH 1/4] roundpb: add the round-status query and report messages In this commit, we add the wire surface for the wavelength#844 status reconcile: a client->server QueryRoundStatus RPC and the ClientRoundStatusReport push event that answers it, carrying a RoundLifecycleStatus classification (in-flight, broadcast, confirmed, or dead). A client that has already sent its per-VTXO forfeit signatures cannot release the forfeit reservations on a round-failure notification alone: the operator may hold fully-signed forfeit txs, and a blind release risks a double-spend if the round's commitment later confirms. The saving grace is that a forfeit tx spends a connector output of the commitment, and the operator persists a finalized round atomically with its VTXOs before the commitment is ever broadcast. A ROUND_STATUS_DEAD answer (no live FSM, no durable row) therefore proves the commitment can never confirm, which is exactly the proof-of-death that makes the release safe. The generated code is regenerated via make rpc. --- rpc/roundpb/round.pb.go | 564 ++++++++++++++++++++--------- rpc/roundpb/round.proto | 62 ++++ rpc/roundpb/round_grpc.pb.go | 46 +++ rpc/roundpb/round_mailboxrpc.pb.go | 35 ++ rpc/roundpb/service.go | 12 + 5 files changed, 542 insertions(+), 177 deletions(-) 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" ) From 9148dcd18f32837a031207b48744fd753b236bfe Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 17:58:42 -0500 Subject: [PATCH 2/4] round: release forfeits via a status reconcile on round death In this commit, we fix wavelength#844: a round failure arriving after the client's forfeit signatures have left the box no longer strands the VTXO in Forfeiting for the life of the batch. The releaseForfeitsOnFailure wrapper (the #653 fix) deliberately stops at PartialSigsSentState, because past that point the operator may hold fully-signed forfeit txs and a blind release risks a double-spend. The result was that InputSigSentState had no release path at all: the coin sat stranded until the #823 startup sweep or batch expiry rescued it. The fix is a status reconcile rather than a table edit. A BoardingFailed that lands in InputSigSentState with forfeits at stake now parks in the state (PendingFailure) while a QueryRoundStatusOutbox probes the operator for the round's authoritative lifecycle status. Only a dead answer, meaning the round never finalized so its commitment can never confirm, fails the round through releaseForfeitsOnFailure, which returns the inputs to LiveState and retires the originating job on a terminal-for-job code. Any other answer holds the reservations. The same probe covers the lumos#618 silence door, where a crashed operator never sends a failure at all: a status-reconcile timeout (armed when the forfeit signatures are emitted, re-armed per probe, re-armed on restart reload) fires in the silence and drives the identical query. The timeout alone never releases; only the operator's answer does. Boarding-only rounds keep the old immediate-failure behavior, and a non-positive StatusReconcileTimeout opts out entirely. The timer only arms when forfeits are actually at stake, matching the gate every consumer applies, and repeated unanswered probes back off exponentially (capped at 16x the base window) so an operator that predates the status RPC sees a bounded probe cadence rather than a fixed-rate loop forever. The dead-answer release also spells out its trust boundary in the code: the proof of death is the operator's own self-report, sound against an honest-but-faulty operator (the failure mode this reconcile exists for), while the commitment confirmation watch stays registered so a fraudulent later broadcast still surfaces as a detected conflict. --- round/actor.go | 88 ++++- round/events.go | 39 +++ round/forfeit_release_on_failure_test.go | 11 +- round/from_proto.go | 23 ++ round/fsm_environment.go | 7 + round/fsm_timeouts.go | 53 +++ round/outbox_messages.go | 38 ++ round/states.go | 18 + round/status_reconcile_test.go | 428 +++++++++++++++++++++++ round/transition_table.go | 34 +- round/transitions.go | 217 +++++++++++- 11 files changed, 929 insertions(+), 27 deletions(-) create mode 100644 round/status_reconcile_test.go 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, From 8fcbf8f69d57bf6df594ec9b5873ce241eccbf6e Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 17:58:50 -0500 Subject: [PATCH 3/4] waved: route the round-status report to the round actor In this commit, we register the inbound dispatch route for ClientRoundStatusReport, so the operator's answer to a QueryRoundStatus probe reaches the round FSM as a RoundStatusReported event through the same push-event path the other round messages use. Without the route the daemon would drop the report on the floor and the reconcile would spin on its retry timeout forever. --- waved/server.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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. From ff13cd57bfac9513aec001cb0c698ceb65979da7 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 19:10:46 -0500 Subject: [PATCH 4/4] db: rebuild reloaded round forfeit sets from vtxo rows In this commit, we close the restart gap in the status-reconcile release. The reconcile keys every decision on Intents.Forfeits, but that set only ever lived in memory: dbRoundToDomainRound and reconstructInputSigSentState rebuilt boarding intents alone, so after a restart a forfeit-bearing round looked boarding-only. The re-arm guard in the actor's reload loop never fired, and even a hand-armed timer would have released nothing, since the dead-answer path releases the (empty) in-memory forfeit list. The strand the reconcile exists to fix simply reopened across every restart. The durable ground truth was already there: MarkVTXOForfeiting stamps each Forfeiting VTXO row with the binding forfeit_round_id. We add a ListForfeitingVTXOsByRound query over those rows and rebuild the forfeit set in both reload paths. The rebuilt requests carry the outpoint and amount with no custom spend paths, which is exactly right: custom (caller-supplied) forfeit inputs never enter the wallet store and their signing contexts die with the process, so everything the query returns is a standard wallet forfeit for the release path. The new TestRoundStoreReloadRebuildsForfeitSet pins the behavior against a real store: commit an InputSigSent round, mark two VTXOs Forfeiting against it, and require both reload paths to surface the pair (and only the pair) as standard forfeits. --- db/round_store.go | 64 ++++++++++++++++++++++++++++++ db/round_store_test.go | 85 ++++++++++++++++++++++++++++++++++++++++ db/sqlc/querier.go | 6 +++ db/sqlc/queries/vtxo.sql | 11 ++++++ db/sqlc/vtxo.sql.go | 41 +++++++++++++++++++ 5 files changed, 207 insertions(+) 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