From 5db9ff508b033e818f3978026cb99d5a7cd89cff Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 14:52:31 -0600 Subject: [PATCH 1/9] events: report a refused watch add in-band with WatchAddRejected An incremental Add* that the watch-set refused (over quota, over the per-add rate limit, over a per-connection cap, missing stream:watch, or malformed as a whole) was logged on the node and dropped. The client got no signal and could believe it was watching addresses it was not. The WatchSet add paths now return the refusal and the net-new items it covered. Both carriers hand it to the outbound task over a blocking channel, like the SetWatchSet result, and emit a WatchAddRejected event (NodeEvent tag 32) on gRPC and its JSON mirror on WS. The event names the kind, the reason with the numbers behind it, and the refused items; a refused descriptor slide says whether the earlier window is kept. The WS per-connection entry cap moves into the watch-set (WatchSet::with_entry_cap), so a capped add is reported like any other and an add that only re-asserts held items is no longer shed at the cap. Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/go/eventspb/events.pb.go | 744 +++++++++++++----- events/src/grpc.rs | 407 ++++++++-- events/src/watchset.rs | 521 +++++++++--- events/src/ws.rs | 430 +++++++--- .../proto/satd/events/v1/events.proto | 70 ++ 5 files changed, 1722 insertions(+), 450 deletions(-) diff --git a/clients/go/eventspb/events.pb.go b/clients/go/eventspb/events.pb.go index e6486d78f..2d3a41b7e 100644 --- a/clients/go/eventspb/events.pb.go +++ b/clients/go/eventspb/events.pb.go @@ -379,6 +379,128 @@ func (WatchSetRejected_Reason) EnumDescriptor() ([]byte, []int) { return file_satd_events_v1_events_proto_rawDescGZIP(), []int{59, 0} } +type WatchAddRejected_Kind int32 + +const ( + WatchAddRejected_KIND_UNSPECIFIED WatchAddRejected_Kind = 0 + WatchAddRejected_SCRIPTS WatchAddRejected_Kind = 1 // AddScripts → scripthashes + WatchAddRejected_OUTPOINTS WatchAddRejected_Kind = 2 // AddOutpoints → outpoints + WatchAddRejected_TRANSACTIONS WatchAddRejected_Kind = 3 // AddTransactions lifecycle watches → txids + WatchAddRejected_DEPTH_ALARMS WatchAddRejected_Kind = 4 // AddTransactions depth alarms → depth_alarms + WatchAddRejected_DESCRIPTOR WatchAddRejected_Kind = 5 // AddDescriptor → descriptor, gap_limit, start + WatchAddRejected_SCRIPT_PREFIXES WatchAddRejected_Kind = 6 // AddScriptPrefixes → prefixes + WatchAddRejected_SILENT_PAYMENTS WatchAddRejected_Kind = 7 // AddSilentPayments → scan_pubkeys +) + +// Enum value maps for WatchAddRejected_Kind. +var ( + WatchAddRejected_Kind_name = map[int32]string{ + 0: "KIND_UNSPECIFIED", + 1: "SCRIPTS", + 2: "OUTPOINTS", + 3: "TRANSACTIONS", + 4: "DEPTH_ALARMS", + 5: "DESCRIPTOR", + 6: "SCRIPT_PREFIXES", + 7: "SILENT_PAYMENTS", + } + WatchAddRejected_Kind_value = map[string]int32{ + "KIND_UNSPECIFIED": 0, + "SCRIPTS": 1, + "OUTPOINTS": 2, + "TRANSACTIONS": 3, + "DEPTH_ALARMS": 4, + "DESCRIPTOR": 5, + "SCRIPT_PREFIXES": 6, + "SILENT_PAYMENTS": 7, + } +) + +func (x WatchAddRejected_Kind) Enum() *WatchAddRejected_Kind { + p := new(WatchAddRejected_Kind) + *p = x + return p +} + +func (x WatchAddRejected_Kind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WatchAddRejected_Kind) Descriptor() protoreflect.EnumDescriptor { + return file_satd_events_v1_events_proto_enumTypes[6].Descriptor() +} + +func (WatchAddRejected_Kind) Type() protoreflect.EnumType { + return &file_satd_events_v1_events_proto_enumTypes[6] +} + +func (x WatchAddRejected_Kind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WatchAddRejected_Kind.Descriptor instead. +func (WatchAddRejected_Kind) EnumDescriptor() ([]byte, []int) { + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{60, 0} +} + +type WatchAddRejected_Reason int32 + +const ( + WatchAddRejected_REASON_UNSPECIFIED WatchAddRejected_Reason = 0 + WatchAddRejected_QUOTA_EXCEEDED WatchAddRejected_Reason = 1 + WatchAddRejected_RATE_LIMITED WatchAddRejected_Reason = 2 + WatchAddRejected_CAP_EXCEEDED WatchAddRejected_Reason = 3 + WatchAddRejected_PERMISSION_DENIED WatchAddRejected_Reason = 4 + WatchAddRejected_MALFORMED WatchAddRejected_Reason = 5 +) + +// Enum value maps for WatchAddRejected_Reason. +var ( + WatchAddRejected_Reason_name = map[int32]string{ + 0: "REASON_UNSPECIFIED", + 1: "QUOTA_EXCEEDED", + 2: "RATE_LIMITED", + 3: "CAP_EXCEEDED", + 4: "PERMISSION_DENIED", + 5: "MALFORMED", + } + WatchAddRejected_Reason_value = map[string]int32{ + "REASON_UNSPECIFIED": 0, + "QUOTA_EXCEEDED": 1, + "RATE_LIMITED": 2, + "CAP_EXCEEDED": 3, + "PERMISSION_DENIED": 4, + "MALFORMED": 5, + } +) + +func (x WatchAddRejected_Reason) Enum() *WatchAddRejected_Reason { + p := new(WatchAddRejected_Reason) + *p = x + return p +} + +func (x WatchAddRejected_Reason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WatchAddRejected_Reason) Descriptor() protoreflect.EnumDescriptor { + return file_satd_events_v1_events_proto_enumTypes[7].Descriptor() +} + +func (WatchAddRejected_Reason) Type() protoreflect.EnumType { + return &file_satd_events_v1_events_proto_enumTypes[7] +} + +func (x WatchAddRejected_Reason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WatchAddRejected_Reason.Descriptor instead. +func (WatchAddRejected_Reason) EnumDescriptor() ([]byte, []int) { + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{60, 1} +} + type RescanRejected_Reason int32 const ( @@ -424,11 +546,11 @@ func (x RescanRejected_Reason) String() string { } func (RescanRejected_Reason) Descriptor() protoreflect.EnumDescriptor { - return file_satd_events_v1_events_proto_enumTypes[6].Descriptor() + return file_satd_events_v1_events_proto_enumTypes[8].Descriptor() } func (RescanRejected_Reason) Type() protoreflect.EnumType { - return &file_satd_events_v1_events_proto_enumTypes[6] + return &file_satd_events_v1_events_proto_enumTypes[8] } func (x RescanRejected_Reason) Number() protoreflect.EnumNumber { @@ -437,7 +559,7 @@ func (x RescanRejected_Reason) Number() protoreflect.EnumNumber { // Deprecated: Use RescanRejected_Reason.Descriptor instead. func (RescanRejected_Reason) EnumDescriptor() ([]byte, []int) { - return file_satd_events_v1_events_proto_rawDescGZIP(), []int{62, 0} + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{63, 0} } // Client→server control on the bidirectional Watch stream. A tagged union @@ -4861,6 +4983,204 @@ func (x *WatchSetRejected) GetQuota() uint64 { return 0 } +// An incremental watch add (AddScripts, AddOutpoints, AddTransactions, +// AddDescriptor, AddScriptPrefixes, AddSilentPayments) that the server did not +// register, delivered in-band on the Watch stream (same pattern as +// WatchSetResult). Emitted once per refused Add* message and never for one that +// registered, so a client that sees none can rely on its adds having landed. +// The add is all-or-nothing over its NET-NEW items: none of the items echoed +// here is watched. Items the message re-asserted (already watched) are not +// echoed and stay watched; their metadata (a script's min_value floor, a +// silent-payment target's labels) still updates, except when the refusal is +// CAP_EXCEEDED or RATE_LIMITED on a silent-payment add, which leaves held +// targets' labels as they were. +// +// `required`/`quota` carry the numbers behind `reason`: +// +// QUOTA_EXCEEDED — `required` = units the refused items cost, `held` = +// units the principal already holds, `quota` = its unit ceiling. Remove +// watches or ask for a larger quota. +// RATE_LIMITED — `retry_after_secs` = when the per-principal rate limit +// admits another add. The same add may then succeed. +// CAP_EXCEEDED — `required` = the count the add would reach, `quota` = the +// per-connection cap it hit: 16 silent-payment targets, 256 descriptors, or +// the WS entry cap (`streamwsmaxsubscriptions`). +// PERMISSION_DENIED — the token lacks `stream:watch`. +// MALFORMED — the message could not be applied as a whole: a +// `min_values` list not parallel to its scripthashes, an invalid descriptor, +// or a txid × depth product over the per-message cap. Echoes the items +// that parsed, except for an over-cap depth product, which echoes none. +// +// Individually unparseable items inside an otherwise valid add (a scripthash +// that is not 32 bytes, a prefix outside the allowed bit range, an invalid +// silent-payment key) are skipped without a rejection. +type WatchAddRejected struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind WatchAddRejected_Kind `protobuf:"varint,1,opt,name=kind,proto3,enum=satd.events.v1.WatchAddRejected_Kind" json:"kind,omitempty"` + Reason WatchAddRejected_Reason `protobuf:"varint,2,opt,name=reason,proto3,enum=satd.events.v1.WatchAddRejected_Reason" json:"reason,omitempty"` + Required uint64 `protobuf:"varint,3,opt,name=required,proto3" json:"required,omitempty"` + Held uint64 `protobuf:"varint,4,opt,name=held,proto3" json:"held,omitempty"` + Quota uint64 `protobuf:"varint,5,opt,name=quota,proto3" json:"quota,omitempty"` + RetryAfterSecs uint32 `protobuf:"varint,6,opt,name=retry_after_secs,json=retryAfterSecs,proto3" json:"retry_after_secs,omitempty"` + // The refused items, as the client named them. Only the field for `kind` is set. + Scripthashes [][]byte `protobuf:"bytes,7,rep,name=scripthashes,proto3" json:"scripthashes,omitempty"` // 32 bytes each + Outpoints []*Outpoint `protobuf:"bytes,8,rep,name=outpoints,proto3" json:"outpoints,omitempty"` + Txids [][]byte `protobuf:"bytes,9,rep,name=txids,proto3" json:"txids,omitempty"` // lifecycle watches, 32 bytes each + DepthAlarms []*WatchDepthAlarm `protobuf:"bytes,10,rep,name=depth_alarms,json=depthAlarms,proto3" json:"depth_alarms,omitempty"` + Descriptor_ string `protobuf:"bytes,11,opt,name=descriptor,proto3" json:"descriptor,omitempty"` + GapLimit uint32 `protobuf:"varint,12,opt,name=gap_limit,json=gapLimit,proto3" json:"gap_limit,omitempty"` // the window the refused AddDescriptor asked for + Start uint32 `protobuf:"varint,13,opt,name=start,proto3" json:"start,omitempty"` + // True when an earlier window of `descriptor` stays watched (a refused slide); + // false when the descriptor is not watched at all. + DescriptorKept bool `protobuf:"varint,14,opt,name=descriptor_kept,json=descriptorKept,proto3" json:"descriptor_kept,omitempty"` + Prefixes []*ScriptPrefix `protobuf:"bytes,15,rep,name=prefixes,proto3" json:"prefixes,omitempty"` // masked to `bits` + ScanPubkeys [][]byte `protobuf:"bytes,16,rep,name=scan_pubkeys,json=scanPubkeys,proto3" json:"scan_pubkeys,omitempty"` // silent-payment identities b_scan·G (33 bytes); never the secret + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchAddRejected) Reset() { + *x = WatchAddRejected{} + mi := &file_satd_events_v1_events_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchAddRejected) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchAddRejected) ProtoMessage() {} + +func (x *WatchAddRejected) ProtoReflect() protoreflect.Message { + mi := &file_satd_events_v1_events_proto_msgTypes[60] + 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 WatchAddRejected.ProtoReflect.Descriptor instead. +func (*WatchAddRejected) Descriptor() ([]byte, []int) { + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{60} +} + +func (x *WatchAddRejected) GetKind() WatchAddRejected_Kind { + if x != nil { + return x.Kind + } + return WatchAddRejected_KIND_UNSPECIFIED +} + +func (x *WatchAddRejected) GetReason() WatchAddRejected_Reason { + if x != nil { + return x.Reason + } + return WatchAddRejected_REASON_UNSPECIFIED +} + +func (x *WatchAddRejected) GetRequired() uint64 { + if x != nil { + return x.Required + } + return 0 +} + +func (x *WatchAddRejected) GetHeld() uint64 { + if x != nil { + return x.Held + } + return 0 +} + +func (x *WatchAddRejected) GetQuota() uint64 { + if x != nil { + return x.Quota + } + return 0 +} + +func (x *WatchAddRejected) GetRetryAfterSecs() uint32 { + if x != nil { + return x.RetryAfterSecs + } + return 0 +} + +func (x *WatchAddRejected) GetScripthashes() [][]byte { + if x != nil { + return x.Scripthashes + } + return nil +} + +func (x *WatchAddRejected) GetOutpoints() []*Outpoint { + if x != nil { + return x.Outpoints + } + return nil +} + +func (x *WatchAddRejected) GetTxids() [][]byte { + if x != nil { + return x.Txids + } + return nil +} + +func (x *WatchAddRejected) GetDepthAlarms() []*WatchDepthAlarm { + if x != nil { + return x.DepthAlarms + } + return nil +} + +func (x *WatchAddRejected) GetDescriptor_() string { + if x != nil { + return x.Descriptor_ + } + return "" +} + +func (x *WatchAddRejected) GetGapLimit() uint32 { + if x != nil { + return x.GapLimit + } + return 0 +} + +func (x *WatchAddRejected) GetStart() uint32 { + if x != nil { + return x.Start + } + return 0 +} + +func (x *WatchAddRejected) GetDescriptorKept() bool { + if x != nil { + return x.DescriptorKept + } + return false +} + +func (x *WatchAddRejected) GetPrefixes() []*ScriptPrefix { + if x != nil { + return x.Prefixes + } + return nil +} + +func (x *WatchAddRejected) GetScanPubkeys() [][]byte { + if x != nil { + return x.ScanPubkeys + } + return nil +} + // Deterministic outcome of a RescanBlocks, delivered in-band ahead of any // matches it admits (same pattern as SetCursorResult). Emitted exactly once per // actionable rescan. On accept, zero or more confirmed watch-match events @@ -4879,7 +5199,7 @@ type RescanResult struct { func (x *RescanResult) Reset() { *x = RescanResult{} - mi := &file_satd_events_v1_events_proto_msgTypes[60] + mi := &file_satd_events_v1_events_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4891,7 +5211,7 @@ func (x *RescanResult) String() string { func (*RescanResult) ProtoMessage() {} func (x *RescanResult) ProtoReflect() protoreflect.Message { - mi := &file_satd_events_v1_events_proto_msgTypes[60] + mi := &file_satd_events_v1_events_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4904,7 +5224,7 @@ func (x *RescanResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RescanResult.ProtoReflect.Descriptor instead. func (*RescanResult) Descriptor() ([]byte, []int) { - return file_satd_events_v1_events_proto_rawDescGZIP(), []int{60} + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{61} } func (x *RescanResult) GetOutcome() isRescanResult_Outcome { @@ -4964,7 +5284,7 @@ type RescanAccepted struct { func (x *RescanAccepted) Reset() { *x = RescanAccepted{} - mi := &file_satd_events_v1_events_proto_msgTypes[61] + mi := &file_satd_events_v1_events_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4976,7 +5296,7 @@ func (x *RescanAccepted) String() string { func (*RescanAccepted) ProtoMessage() {} func (x *RescanAccepted) ProtoReflect() protoreflect.Message { - mi := &file_satd_events_v1_events_proto_msgTypes[61] + mi := &file_satd_events_v1_events_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4989,7 +5309,7 @@ func (x *RescanAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use RescanAccepted.ProtoReflect.Descriptor instead. func (*RescanAccepted) Descriptor() ([]byte, []int) { - return file_satd_events_v1_events_proto_rawDescGZIP(), []int{61} + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{62} } func (x *RescanAccepted) GetFromHeight() uint32 { @@ -5026,7 +5346,7 @@ type RescanRejected struct { func (x *RescanRejected) Reset() { *x = RescanRejected{} - mi := &file_satd_events_v1_events_proto_msgTypes[62] + mi := &file_satd_events_v1_events_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5038,7 +5358,7 @@ func (x *RescanRejected) String() string { func (*RescanRejected) ProtoMessage() {} func (x *RescanRejected) ProtoReflect() protoreflect.Message { - mi := &file_satd_events_v1_events_proto_msgTypes[62] + mi := &file_satd_events_v1_events_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5051,7 +5371,7 @@ func (x *RescanRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use RescanRejected.ProtoReflect.Descriptor instead. func (*RescanRejected) Descriptor() ([]byte, []int) { - return file_satd_events_v1_events_proto_rawDescGZIP(), []int{62} + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{63} } func (x *RescanRejected) GetReason() RescanRejected_Reason { @@ -5082,7 +5402,7 @@ type RescanComplete struct { func (x *RescanComplete) Reset() { *x = RescanComplete{} - mi := &file_satd_events_v1_events_proto_msgTypes[63] + mi := &file_satd_events_v1_events_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5094,7 +5414,7 @@ func (x *RescanComplete) String() string { func (*RescanComplete) ProtoMessage() {} func (x *RescanComplete) ProtoReflect() protoreflect.Message { - mi := &file_satd_events_v1_events_proto_msgTypes[63] + mi := &file_satd_events_v1_events_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5107,7 +5427,7 @@ func (x *RescanComplete) ProtoReflect() protoreflect.Message { // Deprecated: Use RescanComplete.ProtoReflect.Descriptor instead. func (*RescanComplete) Descriptor() ([]byte, []int) { - return file_satd_events_v1_events_proto_rawDescGZIP(), []int{63} + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{64} } func (x *RescanComplete) GetFromHeight() uint32 { @@ -5165,6 +5485,7 @@ type NodeEvent struct { // *NodeEvent_SilentPaymentMatched // *NodeEvent_MempoolTweak // *NodeEvent_Status + // *NodeEvent_WatchAddRejected Body isNodeEvent_Body `protobuf_oneof:"body"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -5172,7 +5493,7 @@ type NodeEvent struct { func (x *NodeEvent) Reset() { *x = NodeEvent{} - mi := &file_satd_events_v1_events_proto_msgTypes[64] + mi := &file_satd_events_v1_events_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5184,7 +5505,7 @@ func (x *NodeEvent) String() string { func (*NodeEvent) ProtoMessage() {} func (x *NodeEvent) ProtoReflect() protoreflect.Message { - mi := &file_satd_events_v1_events_proto_msgTypes[64] + mi := &file_satd_events_v1_events_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5197,7 +5518,7 @@ func (x *NodeEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeEvent.ProtoReflect.Descriptor instead. func (*NodeEvent) Descriptor() ([]byte, []int) { - return file_satd_events_v1_events_proto_rawDescGZIP(), []int{64} + return file_satd_events_v1_events_proto_rawDescGZIP(), []int{65} } func (x *NodeEvent) GetSchemaVersion() uint32 { @@ -5417,6 +5738,15 @@ func (x *NodeEvent) GetStatus() *StatusEvent { return nil } +func (x *NodeEvent) GetWatchAddRejected() *WatchAddRejected { + if x != nil { + if x, ok := x.Body.(*NodeEvent_WatchAddRejected); ok { + return x.WatchAddRejected + } + } + return nil +} + type isNodeEvent_Body interface { isNodeEvent_Body() } @@ -5505,6 +5835,10 @@ type NodeEvent_Status struct { Status *StatusEvent `protobuf:"bytes,31,opt,name=status,proto3,oneof"` // node-health condition (Subscribe `status` category, bit 16) } +type NodeEvent_WatchAddRejected struct { + WatchAddRejected *WatchAddRejected `protobuf:"bytes,32,opt,name=watch_add_rejected,json=watchAddRejected,proto3,oneof"` // an incremental watch add the server did not register (Watch stream) +} + func (*NodeEvent_Mempool) isNodeEvent_Body() {} func (*NodeEvent_Chain) isNodeEvent_Body() {} @@ -5547,6 +5881,8 @@ func (*NodeEvent_MempoolTweak) isNodeEvent_Body() {} func (*NodeEvent_Status) isNodeEvent_Body() {} +func (*NodeEvent_WatchAddRejected) isNodeEvent_Body() {} + var File_satd_events_v1_events_proto protoreflect.FileDescriptor const file_satd_events_v1_events_proto_rawDesc = "" + @@ -5873,7 +6209,44 @@ const file_satd_events_v1_events_proto_rawDesc = "" + "\x12REASON_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eQUOTA_EXCEEDED\x10\x01\x12\r\n" + "\tMALFORMED\x10\x02\x12\x10\n" + - "\fCAP_EXCEEDED\x10\x03\"\x95\x01\n" + + "\fCAP_EXCEEDED\x10\x03\"\xa6\a\n" + + "\x10WatchAddRejected\x129\n" + + "\x04kind\x18\x01 \x01(\x0e2%.satd.events.v1.WatchAddRejected.KindR\x04kind\x12?\n" + + "\x06reason\x18\x02 \x01(\x0e2'.satd.events.v1.WatchAddRejected.ReasonR\x06reason\x12\x1a\n" + + "\brequired\x18\x03 \x01(\x04R\brequired\x12\x12\n" + + "\x04held\x18\x04 \x01(\x04R\x04held\x12\x14\n" + + "\x05quota\x18\x05 \x01(\x04R\x05quota\x12(\n" + + "\x10retry_after_secs\x18\x06 \x01(\rR\x0eretryAfterSecs\x12\"\n" + + "\fscripthashes\x18\a \x03(\fR\fscripthashes\x126\n" + + "\toutpoints\x18\b \x03(\v2\x18.satd.events.v1.OutpointR\toutpoints\x12\x14\n" + + "\x05txids\x18\t \x03(\fR\x05txids\x12B\n" + + "\fdepth_alarms\x18\n" + + " \x03(\v2\x1f.satd.events.v1.WatchDepthAlarmR\vdepthAlarms\x12\x1e\n" + + "\n" + + "descriptor\x18\v \x01(\tR\n" + + "descriptor\x12\x1b\n" + + "\tgap_limit\x18\f \x01(\rR\bgapLimit\x12\x14\n" + + "\x05start\x18\r \x01(\rR\x05start\x12'\n" + + "\x0fdescriptor_kept\x18\x0e \x01(\bR\x0edescriptorKept\x128\n" + + "\bprefixes\x18\x0f \x03(\v2\x1c.satd.events.v1.ScriptPrefixR\bprefixes\x12!\n" + + "\fscan_pubkeys\x18\x10 \x03(\fR\vscanPubkeys\"\x96\x01\n" + + "\x04Kind\x12\x14\n" + + "\x10KIND_UNSPECIFIED\x10\x00\x12\v\n" + + "\aSCRIPTS\x10\x01\x12\r\n" + + "\tOUTPOINTS\x10\x02\x12\x10\n" + + "\fTRANSACTIONS\x10\x03\x12\x10\n" + + "\fDEPTH_ALARMS\x10\x04\x12\x0e\n" + + "\n" + + "DESCRIPTOR\x10\x05\x12\x13\n" + + "\x0fSCRIPT_PREFIXES\x10\x06\x12\x13\n" + + "\x0fSILENT_PAYMENTS\x10\a\"~\n" + + "\x06Reason\x12\x16\n" + + "\x12REASON_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eQUOTA_EXCEEDED\x10\x01\x12\x10\n" + + "\fRATE_LIMITED\x10\x02\x12\x10\n" + + "\fCAP_EXCEEDED\x10\x03\x12\x15\n" + + "\x11PERMISSION_DENIED\x10\x04\x12\r\n" + + "\tMALFORMED\x10\x05\"\x95\x01\n" + "\fRescanResult\x12<\n" + "\baccepted\x18\x01 \x01(\v2\x1e.satd.events.v1.RescanAcceptedH\x00R\baccepted\x12<\n" + "\brejected\x18\x02 \x01(\v2\x1e.satd.events.v1.RescanRejectedH\x00R\brejectedB\t\n" + @@ -5899,7 +6272,7 @@ const file_satd_events_v1_events_proto_rawDesc = "" + "\vfrom_height\x18\x01 \x01(\rR\n" + "fromHeight\x12\x1b\n" + "\tto_height\x18\x02 \x01(\rR\btoHeight\x12\x18\n" + - "\amatches\x18\x03 \x01(\x04R\amatches\"\xef\f\n" + + "\amatches\x18\x03 \x01(\x04R\amatches\"\xc1\r\n" + "\tNodeEvent\x12%\n" + "\x0eschema_version\x18\x01 \x01(\rR\rschemaVersion\x12/\n" + "\x05stamp\x18\x02 \x01(\v2\x19.satd.events.v1.EdgeStampR\x05stamp\x12.\n" + @@ -5925,7 +6298,8 @@ const file_satd_events_v1_events_proto_rawDesc = "" + "\fblock_tweaks\x18\x1c \x01(\v2\x1b.satd.events.v1.BlockTweaksH\x00R\vblockTweaks\x12\\\n" + "\x16silent_payment_matched\x18\x1d \x01(\v2$.satd.events.v1.SilentPaymentMatchedH\x00R\x14silentPaymentMatched\x12C\n" + "\rmempool_tweak\x18\x1e \x01(\v2\x1c.satd.events.v1.MempoolTweakH\x00R\fmempoolTweak\x125\n" + - "\x06status\x18\x1f \x01(\v2\x1b.satd.events.v1.StatusEventH\x00R\x06statusB\x06\n" + + "\x06status\x18\x1f \x01(\v2\x1b.satd.events.v1.StatusEventH\x00R\x06status\x12P\n" + + "\x12watch_add_rejected\x18 \x01(\v2 .satd.events.v1.WatchAddRejectedH\x00R\x10watchAddRejectedB\x06\n" + "\x04bodyJ\x04\b\x0f\x10\x10R\x1adescriptor_needs_addresses*\xb2\x01\n" + "\vEvictReason\x12\x1c\n" + "\x18EVICT_REASON_UNSPECIFIED\x10\x00\x12\x1a\n" + @@ -5971,8 +6345,8 @@ func file_satd_events_v1_events_proto_rawDescGZIP() []byte { return file_satd_events_v1_events_proto_rawDescData } -var file_satd_events_v1_events_proto_enumTypes = make([]protoimpl.EnumInfo, 7) -var file_satd_events_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 66) +var file_satd_events_v1_events_proto_enumTypes = make([]protoimpl.EnumInfo, 9) +var file_satd_events_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 67) var file_satd_events_v1_events_proto_goTypes = []any{ (EvictReason)(0), // 0: satd.events.v1.EvictReason (StatusKind)(0), // 1: satd.events.v1.StatusKind @@ -5980,167 +6354,176 @@ var file_satd_events_v1_events_proto_goTypes = []any{ (StatusSeverity)(0), // 3: satd.events.v1.StatusSeverity (CursorRejected_Reason)(0), // 4: satd.events.v1.CursorRejected.Reason (WatchSetRejected_Reason)(0), // 5: satd.events.v1.WatchSetRejected.Reason - (RescanRejected_Reason)(0), // 6: satd.events.v1.RescanRejected.Reason - (*SubscribeControl)(nil), // 7: satd.events.v1.SubscribeControl - (*SetWatchOptions)(nil), // 8: satd.events.v1.SetWatchOptions - (*RescanBlocks)(nil), // 9: satd.events.v1.RescanBlocks - (*SetWatchSet)(nil), // 10: satd.events.v1.SetWatchSet - (*WatchLifecycle)(nil), // 11: satd.events.v1.WatchLifecycle - (*WatchDepthAlarm)(nil), // 12: satd.events.v1.WatchDepthAlarm - (*AddDescriptor)(nil), // 13: satd.events.v1.AddDescriptor - (*RemoveDescriptor)(nil), // 14: satd.events.v1.RemoveDescriptor - (*SetCursor)(nil), // 15: satd.events.v1.SetCursor - (*SetCategories)(nil), // 16: satd.events.v1.SetCategories - (*Outpoint)(nil), // 17: satd.events.v1.Outpoint - (*AddScripts)(nil), // 18: satd.events.v1.AddScripts - (*RemoveScripts)(nil), // 19: satd.events.v1.RemoveScripts - (*AddOutpoints)(nil), // 20: satd.events.v1.AddOutpoints - (*RemoveOutpoints)(nil), // 21: satd.events.v1.RemoveOutpoints - (*AddTransactions)(nil), // 22: satd.events.v1.AddTransactions - (*RemoveTransactions)(nil), // 23: satd.events.v1.RemoveTransactions - (*ScriptPrefix)(nil), // 24: satd.events.v1.ScriptPrefix - (*AddScriptPrefixes)(nil), // 25: satd.events.v1.AddScriptPrefixes - (*RemoveScriptPrefixes)(nil), // 26: satd.events.v1.RemoveScriptPrefixes - (*SilentPaymentTarget)(nil), // 27: satd.events.v1.SilentPaymentTarget - (*AddSilentPayments)(nil), // 28: satd.events.v1.AddSilentPayments - (*RemoveSilentPayments)(nil), // 29: satd.events.v1.RemoveSilentPayments - (*OutpointSpent)(nil), // 30: satd.events.v1.OutpointSpent - (*ScriptMatched)(nil), // 31: satd.events.v1.ScriptMatched - (*DescriptorMatch)(nil), // 32: satd.events.v1.DescriptorMatch - (*TxidMatched)(nil), // 33: satd.events.v1.TxidMatched - (*TxidReplaced)(nil), // 34: satd.events.v1.TxidReplaced - (*TxidEvicted)(nil), // 35: satd.events.v1.TxidEvicted - (*TxidUnconfirmed)(nil), // 36: satd.events.v1.TxidUnconfirmed - (*TxidDepthReached)(nil), // 37: satd.events.v1.TxidDepthReached - (*TxidFinalized)(nil), // 38: satd.events.v1.TxidFinalized - (*SpentPrevout)(nil), // 39: satd.events.v1.SpentPrevout - (*PrefixMatched)(nil), // 40: satd.events.v1.PrefixMatched - (*SilentPaymentMatched)(nil), // 41: satd.events.v1.SilentPaymentMatched - (*BlockTweaks)(nil), // 42: satd.events.v1.BlockTweaks - (*TaprootOutput)(nil), // 43: satd.events.v1.TaprootOutput - (*TweakEntry)(nil), // 44: satd.events.v1.TweakEntry - (*MempoolTweak)(nil), // 45: satd.events.v1.MempoolTweak - (*SubscribeRequest)(nil), // 46: satd.events.v1.SubscribeRequest - (*Cursor)(nil), // 47: satd.events.v1.Cursor - (*EdgeStamp)(nil), // 48: satd.events.v1.EdgeStamp - (*MempoolEvent)(nil), // 49: satd.events.v1.MempoolEvent - (*MempoolEnter)(nil), // 50: satd.events.v1.MempoolEnter - (*MempoolLeaveConfirmed)(nil), // 51: satd.events.v1.MempoolLeaveConfirmed - (*MempoolLeaveEvicted)(nil), // 52: satd.events.v1.MempoolLeaveEvicted - (*MempoolLeaveReplaced)(nil), // 53: satd.events.v1.MempoolLeaveReplaced - (*ChainEvent)(nil), // 54: satd.events.v1.ChainEvent - (*BlockConnected)(nil), // 55: satd.events.v1.BlockConnected - (*BlockDisconnected)(nil), // 56: satd.events.v1.BlockDisconnected - (*Reorg)(nil), // 57: satd.events.v1.Reorg - (*Heartbeat)(nil), // 58: satd.events.v1.Heartbeat - (*StatusEvent)(nil), // 59: satd.events.v1.StatusEvent - (*Lagged)(nil), // 60: satd.events.v1.Lagged - (*SetCursorResult)(nil), // 61: satd.events.v1.SetCursorResult - (*CursorAccepted)(nil), // 62: satd.events.v1.CursorAccepted - (*CursorRejected)(nil), // 63: satd.events.v1.CursorRejected - (*WatchSetResult)(nil), // 64: satd.events.v1.WatchSetResult - (*WatchSetAccepted)(nil), // 65: satd.events.v1.WatchSetAccepted - (*WatchSetRejected)(nil), // 66: satd.events.v1.WatchSetRejected - (*RescanResult)(nil), // 67: satd.events.v1.RescanResult - (*RescanAccepted)(nil), // 68: satd.events.v1.RescanAccepted - (*RescanRejected)(nil), // 69: satd.events.v1.RescanRejected - (*RescanComplete)(nil), // 70: satd.events.v1.RescanComplete - (*NodeEvent)(nil), // 71: satd.events.v1.NodeEvent - nil, // 72: satd.events.v1.StatusEvent.DetailsEntry + (WatchAddRejected_Kind)(0), // 6: satd.events.v1.WatchAddRejected.Kind + (WatchAddRejected_Reason)(0), // 7: satd.events.v1.WatchAddRejected.Reason + (RescanRejected_Reason)(0), // 8: satd.events.v1.RescanRejected.Reason + (*SubscribeControl)(nil), // 9: satd.events.v1.SubscribeControl + (*SetWatchOptions)(nil), // 10: satd.events.v1.SetWatchOptions + (*RescanBlocks)(nil), // 11: satd.events.v1.RescanBlocks + (*SetWatchSet)(nil), // 12: satd.events.v1.SetWatchSet + (*WatchLifecycle)(nil), // 13: satd.events.v1.WatchLifecycle + (*WatchDepthAlarm)(nil), // 14: satd.events.v1.WatchDepthAlarm + (*AddDescriptor)(nil), // 15: satd.events.v1.AddDescriptor + (*RemoveDescriptor)(nil), // 16: satd.events.v1.RemoveDescriptor + (*SetCursor)(nil), // 17: satd.events.v1.SetCursor + (*SetCategories)(nil), // 18: satd.events.v1.SetCategories + (*Outpoint)(nil), // 19: satd.events.v1.Outpoint + (*AddScripts)(nil), // 20: satd.events.v1.AddScripts + (*RemoveScripts)(nil), // 21: satd.events.v1.RemoveScripts + (*AddOutpoints)(nil), // 22: satd.events.v1.AddOutpoints + (*RemoveOutpoints)(nil), // 23: satd.events.v1.RemoveOutpoints + (*AddTransactions)(nil), // 24: satd.events.v1.AddTransactions + (*RemoveTransactions)(nil), // 25: satd.events.v1.RemoveTransactions + (*ScriptPrefix)(nil), // 26: satd.events.v1.ScriptPrefix + (*AddScriptPrefixes)(nil), // 27: satd.events.v1.AddScriptPrefixes + (*RemoveScriptPrefixes)(nil), // 28: satd.events.v1.RemoveScriptPrefixes + (*SilentPaymentTarget)(nil), // 29: satd.events.v1.SilentPaymentTarget + (*AddSilentPayments)(nil), // 30: satd.events.v1.AddSilentPayments + (*RemoveSilentPayments)(nil), // 31: satd.events.v1.RemoveSilentPayments + (*OutpointSpent)(nil), // 32: satd.events.v1.OutpointSpent + (*ScriptMatched)(nil), // 33: satd.events.v1.ScriptMatched + (*DescriptorMatch)(nil), // 34: satd.events.v1.DescriptorMatch + (*TxidMatched)(nil), // 35: satd.events.v1.TxidMatched + (*TxidReplaced)(nil), // 36: satd.events.v1.TxidReplaced + (*TxidEvicted)(nil), // 37: satd.events.v1.TxidEvicted + (*TxidUnconfirmed)(nil), // 38: satd.events.v1.TxidUnconfirmed + (*TxidDepthReached)(nil), // 39: satd.events.v1.TxidDepthReached + (*TxidFinalized)(nil), // 40: satd.events.v1.TxidFinalized + (*SpentPrevout)(nil), // 41: satd.events.v1.SpentPrevout + (*PrefixMatched)(nil), // 42: satd.events.v1.PrefixMatched + (*SilentPaymentMatched)(nil), // 43: satd.events.v1.SilentPaymentMatched + (*BlockTweaks)(nil), // 44: satd.events.v1.BlockTweaks + (*TaprootOutput)(nil), // 45: satd.events.v1.TaprootOutput + (*TweakEntry)(nil), // 46: satd.events.v1.TweakEntry + (*MempoolTweak)(nil), // 47: satd.events.v1.MempoolTweak + (*SubscribeRequest)(nil), // 48: satd.events.v1.SubscribeRequest + (*Cursor)(nil), // 49: satd.events.v1.Cursor + (*EdgeStamp)(nil), // 50: satd.events.v1.EdgeStamp + (*MempoolEvent)(nil), // 51: satd.events.v1.MempoolEvent + (*MempoolEnter)(nil), // 52: satd.events.v1.MempoolEnter + (*MempoolLeaveConfirmed)(nil), // 53: satd.events.v1.MempoolLeaveConfirmed + (*MempoolLeaveEvicted)(nil), // 54: satd.events.v1.MempoolLeaveEvicted + (*MempoolLeaveReplaced)(nil), // 55: satd.events.v1.MempoolLeaveReplaced + (*ChainEvent)(nil), // 56: satd.events.v1.ChainEvent + (*BlockConnected)(nil), // 57: satd.events.v1.BlockConnected + (*BlockDisconnected)(nil), // 58: satd.events.v1.BlockDisconnected + (*Reorg)(nil), // 59: satd.events.v1.Reorg + (*Heartbeat)(nil), // 60: satd.events.v1.Heartbeat + (*StatusEvent)(nil), // 61: satd.events.v1.StatusEvent + (*Lagged)(nil), // 62: satd.events.v1.Lagged + (*SetCursorResult)(nil), // 63: satd.events.v1.SetCursorResult + (*CursorAccepted)(nil), // 64: satd.events.v1.CursorAccepted + (*CursorRejected)(nil), // 65: satd.events.v1.CursorRejected + (*WatchSetResult)(nil), // 66: satd.events.v1.WatchSetResult + (*WatchSetAccepted)(nil), // 67: satd.events.v1.WatchSetAccepted + (*WatchSetRejected)(nil), // 68: satd.events.v1.WatchSetRejected + (*WatchAddRejected)(nil), // 69: satd.events.v1.WatchAddRejected + (*RescanResult)(nil), // 70: satd.events.v1.RescanResult + (*RescanAccepted)(nil), // 71: satd.events.v1.RescanAccepted + (*RescanRejected)(nil), // 72: satd.events.v1.RescanRejected + (*RescanComplete)(nil), // 73: satd.events.v1.RescanComplete + (*NodeEvent)(nil), // 74: satd.events.v1.NodeEvent + nil, // 75: satd.events.v1.StatusEvent.DetailsEntry } var file_satd_events_v1_events_proto_depIdxs = []int32{ - 15, // 0: satd.events.v1.SubscribeControl.set_cursor:type_name -> satd.events.v1.SetCursor - 16, // 1: satd.events.v1.SubscribeControl.set_categories:type_name -> satd.events.v1.SetCategories - 18, // 2: satd.events.v1.SubscribeControl.add_scripts:type_name -> satd.events.v1.AddScripts - 19, // 3: satd.events.v1.SubscribeControl.remove_scripts:type_name -> satd.events.v1.RemoveScripts - 20, // 4: satd.events.v1.SubscribeControl.add_outpoints:type_name -> satd.events.v1.AddOutpoints - 21, // 5: satd.events.v1.SubscribeControl.remove_outpoints:type_name -> satd.events.v1.RemoveOutpoints - 13, // 6: satd.events.v1.SubscribeControl.add_descriptor:type_name -> satd.events.v1.AddDescriptor - 14, // 7: satd.events.v1.SubscribeControl.remove_descriptor:type_name -> satd.events.v1.RemoveDescriptor - 22, // 8: satd.events.v1.SubscribeControl.add_transactions:type_name -> satd.events.v1.AddTransactions - 23, // 9: satd.events.v1.SubscribeControl.remove_transactions:type_name -> satd.events.v1.RemoveTransactions - 25, // 10: satd.events.v1.SubscribeControl.add_script_prefixes:type_name -> satd.events.v1.AddScriptPrefixes - 26, // 11: satd.events.v1.SubscribeControl.remove_script_prefixes:type_name -> satd.events.v1.RemoveScriptPrefixes - 10, // 12: satd.events.v1.SubscribeControl.set_watch_set:type_name -> satd.events.v1.SetWatchSet - 9, // 13: satd.events.v1.SubscribeControl.rescan_blocks:type_name -> satd.events.v1.RescanBlocks - 8, // 14: satd.events.v1.SubscribeControl.set_watch_options:type_name -> satd.events.v1.SetWatchOptions - 28, // 15: satd.events.v1.SubscribeControl.add_silent_payments:type_name -> satd.events.v1.AddSilentPayments - 29, // 16: satd.events.v1.SubscribeControl.remove_silent_payments:type_name -> satd.events.v1.RemoveSilentPayments - 17, // 17: satd.events.v1.SetWatchSet.outpoints:type_name -> satd.events.v1.Outpoint - 13, // 18: satd.events.v1.SetWatchSet.descriptors:type_name -> satd.events.v1.AddDescriptor - 24, // 19: satd.events.v1.SetWatchSet.prefixes:type_name -> satd.events.v1.ScriptPrefix - 11, // 20: satd.events.v1.SetWatchSet.lifecycles:type_name -> satd.events.v1.WatchLifecycle - 12, // 21: satd.events.v1.SetWatchSet.depth_alarms:type_name -> satd.events.v1.WatchDepthAlarm - 27, // 22: satd.events.v1.SetWatchSet.silent_payments:type_name -> satd.events.v1.SilentPaymentTarget - 47, // 23: satd.events.v1.SetCursor.cursor:type_name -> satd.events.v1.Cursor - 17, // 24: satd.events.v1.AddOutpoints.outpoints:type_name -> satd.events.v1.Outpoint - 17, // 25: satd.events.v1.RemoveOutpoints.outpoints:type_name -> satd.events.v1.Outpoint - 24, // 26: satd.events.v1.AddScriptPrefixes.prefixes:type_name -> satd.events.v1.ScriptPrefix - 24, // 27: satd.events.v1.RemoveScriptPrefixes.prefixes:type_name -> satd.events.v1.ScriptPrefix - 27, // 28: satd.events.v1.AddSilentPayments.targets:type_name -> satd.events.v1.SilentPaymentTarget - 32, // 29: satd.events.v1.ScriptMatched.descriptor_matches:type_name -> satd.events.v1.DescriptorMatch - 24, // 30: satd.events.v1.PrefixMatched.prefix:type_name -> satd.events.v1.ScriptPrefix - 39, // 31: satd.events.v1.PrefixMatched.matched_prevouts:type_name -> satd.events.v1.SpentPrevout - 44, // 32: satd.events.v1.BlockTweaks.entries:type_name -> satd.events.v1.TweakEntry - 43, // 33: satd.events.v1.TweakEntry.taproot_outputs:type_name -> satd.events.v1.TaprootOutput - 44, // 34: satd.events.v1.MempoolTweak.entry:type_name -> satd.events.v1.TweakEntry - 47, // 35: satd.events.v1.SubscribeRequest.from_cursor:type_name -> satd.events.v1.Cursor - 50, // 36: satd.events.v1.MempoolEvent.enter:type_name -> satd.events.v1.MempoolEnter - 51, // 37: satd.events.v1.MempoolEvent.leave_confirmed:type_name -> satd.events.v1.MempoolLeaveConfirmed - 52, // 38: satd.events.v1.MempoolEvent.leave_evicted:type_name -> satd.events.v1.MempoolLeaveEvicted - 53, // 39: satd.events.v1.MempoolEvent.leave_replaced:type_name -> satd.events.v1.MempoolLeaveReplaced + 17, // 0: satd.events.v1.SubscribeControl.set_cursor:type_name -> satd.events.v1.SetCursor + 18, // 1: satd.events.v1.SubscribeControl.set_categories:type_name -> satd.events.v1.SetCategories + 20, // 2: satd.events.v1.SubscribeControl.add_scripts:type_name -> satd.events.v1.AddScripts + 21, // 3: satd.events.v1.SubscribeControl.remove_scripts:type_name -> satd.events.v1.RemoveScripts + 22, // 4: satd.events.v1.SubscribeControl.add_outpoints:type_name -> satd.events.v1.AddOutpoints + 23, // 5: satd.events.v1.SubscribeControl.remove_outpoints:type_name -> satd.events.v1.RemoveOutpoints + 15, // 6: satd.events.v1.SubscribeControl.add_descriptor:type_name -> satd.events.v1.AddDescriptor + 16, // 7: satd.events.v1.SubscribeControl.remove_descriptor:type_name -> satd.events.v1.RemoveDescriptor + 24, // 8: satd.events.v1.SubscribeControl.add_transactions:type_name -> satd.events.v1.AddTransactions + 25, // 9: satd.events.v1.SubscribeControl.remove_transactions:type_name -> satd.events.v1.RemoveTransactions + 27, // 10: satd.events.v1.SubscribeControl.add_script_prefixes:type_name -> satd.events.v1.AddScriptPrefixes + 28, // 11: satd.events.v1.SubscribeControl.remove_script_prefixes:type_name -> satd.events.v1.RemoveScriptPrefixes + 12, // 12: satd.events.v1.SubscribeControl.set_watch_set:type_name -> satd.events.v1.SetWatchSet + 11, // 13: satd.events.v1.SubscribeControl.rescan_blocks:type_name -> satd.events.v1.RescanBlocks + 10, // 14: satd.events.v1.SubscribeControl.set_watch_options:type_name -> satd.events.v1.SetWatchOptions + 30, // 15: satd.events.v1.SubscribeControl.add_silent_payments:type_name -> satd.events.v1.AddSilentPayments + 31, // 16: satd.events.v1.SubscribeControl.remove_silent_payments:type_name -> satd.events.v1.RemoveSilentPayments + 19, // 17: satd.events.v1.SetWatchSet.outpoints:type_name -> satd.events.v1.Outpoint + 15, // 18: satd.events.v1.SetWatchSet.descriptors:type_name -> satd.events.v1.AddDescriptor + 26, // 19: satd.events.v1.SetWatchSet.prefixes:type_name -> satd.events.v1.ScriptPrefix + 13, // 20: satd.events.v1.SetWatchSet.lifecycles:type_name -> satd.events.v1.WatchLifecycle + 14, // 21: satd.events.v1.SetWatchSet.depth_alarms:type_name -> satd.events.v1.WatchDepthAlarm + 29, // 22: satd.events.v1.SetWatchSet.silent_payments:type_name -> satd.events.v1.SilentPaymentTarget + 49, // 23: satd.events.v1.SetCursor.cursor:type_name -> satd.events.v1.Cursor + 19, // 24: satd.events.v1.AddOutpoints.outpoints:type_name -> satd.events.v1.Outpoint + 19, // 25: satd.events.v1.RemoveOutpoints.outpoints:type_name -> satd.events.v1.Outpoint + 26, // 26: satd.events.v1.AddScriptPrefixes.prefixes:type_name -> satd.events.v1.ScriptPrefix + 26, // 27: satd.events.v1.RemoveScriptPrefixes.prefixes:type_name -> satd.events.v1.ScriptPrefix + 29, // 28: satd.events.v1.AddSilentPayments.targets:type_name -> satd.events.v1.SilentPaymentTarget + 34, // 29: satd.events.v1.ScriptMatched.descriptor_matches:type_name -> satd.events.v1.DescriptorMatch + 26, // 30: satd.events.v1.PrefixMatched.prefix:type_name -> satd.events.v1.ScriptPrefix + 41, // 31: satd.events.v1.PrefixMatched.matched_prevouts:type_name -> satd.events.v1.SpentPrevout + 46, // 32: satd.events.v1.BlockTweaks.entries:type_name -> satd.events.v1.TweakEntry + 45, // 33: satd.events.v1.TweakEntry.taproot_outputs:type_name -> satd.events.v1.TaprootOutput + 46, // 34: satd.events.v1.MempoolTweak.entry:type_name -> satd.events.v1.TweakEntry + 49, // 35: satd.events.v1.SubscribeRequest.from_cursor:type_name -> satd.events.v1.Cursor + 52, // 36: satd.events.v1.MempoolEvent.enter:type_name -> satd.events.v1.MempoolEnter + 53, // 37: satd.events.v1.MempoolEvent.leave_confirmed:type_name -> satd.events.v1.MempoolLeaveConfirmed + 54, // 38: satd.events.v1.MempoolEvent.leave_evicted:type_name -> satd.events.v1.MempoolLeaveEvicted + 55, // 39: satd.events.v1.MempoolEvent.leave_replaced:type_name -> satd.events.v1.MempoolLeaveReplaced 0, // 40: satd.events.v1.MempoolLeaveEvicted.reason:type_name -> satd.events.v1.EvictReason - 55, // 41: satd.events.v1.ChainEvent.block_connected:type_name -> satd.events.v1.BlockConnected - 56, // 42: satd.events.v1.ChainEvent.block_disconnected:type_name -> satd.events.v1.BlockDisconnected - 57, // 43: satd.events.v1.ChainEvent.reorg:type_name -> satd.events.v1.Reorg + 57, // 41: satd.events.v1.ChainEvent.block_connected:type_name -> satd.events.v1.BlockConnected + 58, // 42: satd.events.v1.ChainEvent.block_disconnected:type_name -> satd.events.v1.BlockDisconnected + 59, // 43: satd.events.v1.ChainEvent.reorg:type_name -> satd.events.v1.Reorg 1, // 44: satd.events.v1.StatusEvent.kind:type_name -> satd.events.v1.StatusKind 2, // 45: satd.events.v1.StatusEvent.state:type_name -> satd.events.v1.StatusState 3, // 46: satd.events.v1.StatusEvent.severity:type_name -> satd.events.v1.StatusSeverity - 72, // 47: satd.events.v1.StatusEvent.details:type_name -> satd.events.v1.StatusEvent.DetailsEntry - 47, // 48: satd.events.v1.Lagged.resume_cursor:type_name -> satd.events.v1.Cursor - 62, // 49: satd.events.v1.SetCursorResult.accepted:type_name -> satd.events.v1.CursorAccepted - 63, // 50: satd.events.v1.SetCursorResult.rejected:type_name -> satd.events.v1.CursorRejected - 47, // 51: satd.events.v1.CursorAccepted.from:type_name -> satd.events.v1.Cursor + 75, // 47: satd.events.v1.StatusEvent.details:type_name -> satd.events.v1.StatusEvent.DetailsEntry + 49, // 48: satd.events.v1.Lagged.resume_cursor:type_name -> satd.events.v1.Cursor + 64, // 49: satd.events.v1.SetCursorResult.accepted:type_name -> satd.events.v1.CursorAccepted + 65, // 50: satd.events.v1.SetCursorResult.rejected:type_name -> satd.events.v1.CursorRejected + 49, // 51: satd.events.v1.CursorAccepted.from:type_name -> satd.events.v1.Cursor 4, // 52: satd.events.v1.CursorRejected.reason:type_name -> satd.events.v1.CursorRejected.Reason - 47, // 53: satd.events.v1.CursorRejected.current_head:type_name -> satd.events.v1.Cursor - 65, // 54: satd.events.v1.WatchSetResult.accepted:type_name -> satd.events.v1.WatchSetAccepted - 66, // 55: satd.events.v1.WatchSetResult.rejected:type_name -> satd.events.v1.WatchSetRejected + 49, // 53: satd.events.v1.CursorRejected.current_head:type_name -> satd.events.v1.Cursor + 67, // 54: satd.events.v1.WatchSetResult.accepted:type_name -> satd.events.v1.WatchSetAccepted + 68, // 55: satd.events.v1.WatchSetResult.rejected:type_name -> satd.events.v1.WatchSetRejected 5, // 56: satd.events.v1.WatchSetRejected.reason:type_name -> satd.events.v1.WatchSetRejected.Reason - 68, // 57: satd.events.v1.RescanResult.accepted:type_name -> satd.events.v1.RescanAccepted - 69, // 58: satd.events.v1.RescanResult.rejected:type_name -> satd.events.v1.RescanRejected - 6, // 59: satd.events.v1.RescanRejected.reason:type_name -> satd.events.v1.RescanRejected.Reason - 48, // 60: satd.events.v1.NodeEvent.stamp:type_name -> satd.events.v1.EdgeStamp - 47, // 61: satd.events.v1.NodeEvent.cursor:type_name -> satd.events.v1.Cursor - 49, // 62: satd.events.v1.NodeEvent.mempool:type_name -> satd.events.v1.MempoolEvent - 54, // 63: satd.events.v1.NodeEvent.chain:type_name -> satd.events.v1.ChainEvent - 58, // 64: satd.events.v1.NodeEvent.heartbeat:type_name -> satd.events.v1.Heartbeat - 30, // 65: satd.events.v1.NodeEvent.outpoint_spent:type_name -> satd.events.v1.OutpointSpent - 31, // 66: satd.events.v1.NodeEvent.script_matched:type_name -> satd.events.v1.ScriptMatched - 60, // 67: satd.events.v1.NodeEvent.lagged:type_name -> satd.events.v1.Lagged - 33, // 68: satd.events.v1.NodeEvent.txid_matched:type_name -> satd.events.v1.TxidMatched - 34, // 69: satd.events.v1.NodeEvent.txid_replaced:type_name -> satd.events.v1.TxidReplaced - 35, // 70: satd.events.v1.NodeEvent.txid_evicted:type_name -> satd.events.v1.TxidEvicted - 36, // 71: satd.events.v1.NodeEvent.txid_unconfirmed:type_name -> satd.events.v1.TxidUnconfirmed - 37, // 72: satd.events.v1.NodeEvent.txid_depth_reached:type_name -> satd.events.v1.TxidDepthReached - 38, // 73: satd.events.v1.NodeEvent.txid_finalized:type_name -> satd.events.v1.TxidFinalized - 40, // 74: satd.events.v1.NodeEvent.prefix_matched:type_name -> satd.events.v1.PrefixMatched - 61, // 75: satd.events.v1.NodeEvent.set_cursor_result:type_name -> satd.events.v1.SetCursorResult - 64, // 76: satd.events.v1.NodeEvent.set_watch_set_result:type_name -> satd.events.v1.WatchSetResult - 67, // 77: satd.events.v1.NodeEvent.rescan_result:type_name -> satd.events.v1.RescanResult - 70, // 78: satd.events.v1.NodeEvent.rescan_complete:type_name -> satd.events.v1.RescanComplete - 42, // 79: satd.events.v1.NodeEvent.block_tweaks:type_name -> satd.events.v1.BlockTweaks - 41, // 80: satd.events.v1.NodeEvent.silent_payment_matched:type_name -> satd.events.v1.SilentPaymentMatched - 45, // 81: satd.events.v1.NodeEvent.mempool_tweak:type_name -> satd.events.v1.MempoolTweak - 59, // 82: satd.events.v1.NodeEvent.status:type_name -> satd.events.v1.StatusEvent - 46, // 83: satd.events.v1.NodeEventStream.Subscribe:input_type -> satd.events.v1.SubscribeRequest - 7, // 84: satd.events.v1.NodeEventStream.Watch:input_type -> satd.events.v1.SubscribeControl - 71, // 85: satd.events.v1.NodeEventStream.Subscribe:output_type -> satd.events.v1.NodeEvent - 71, // 86: satd.events.v1.NodeEventStream.Watch:output_type -> satd.events.v1.NodeEvent - 85, // [85:87] is the sub-list for method output_type - 83, // [83:85] is the sub-list for method input_type - 83, // [83:83] is the sub-list for extension type_name - 83, // [83:83] is the sub-list for extension extendee - 0, // [0:83] is the sub-list for field type_name + 6, // 57: satd.events.v1.WatchAddRejected.kind:type_name -> satd.events.v1.WatchAddRejected.Kind + 7, // 58: satd.events.v1.WatchAddRejected.reason:type_name -> satd.events.v1.WatchAddRejected.Reason + 19, // 59: satd.events.v1.WatchAddRejected.outpoints:type_name -> satd.events.v1.Outpoint + 14, // 60: satd.events.v1.WatchAddRejected.depth_alarms:type_name -> satd.events.v1.WatchDepthAlarm + 26, // 61: satd.events.v1.WatchAddRejected.prefixes:type_name -> satd.events.v1.ScriptPrefix + 71, // 62: satd.events.v1.RescanResult.accepted:type_name -> satd.events.v1.RescanAccepted + 72, // 63: satd.events.v1.RescanResult.rejected:type_name -> satd.events.v1.RescanRejected + 8, // 64: satd.events.v1.RescanRejected.reason:type_name -> satd.events.v1.RescanRejected.Reason + 50, // 65: satd.events.v1.NodeEvent.stamp:type_name -> satd.events.v1.EdgeStamp + 49, // 66: satd.events.v1.NodeEvent.cursor:type_name -> satd.events.v1.Cursor + 51, // 67: satd.events.v1.NodeEvent.mempool:type_name -> satd.events.v1.MempoolEvent + 56, // 68: satd.events.v1.NodeEvent.chain:type_name -> satd.events.v1.ChainEvent + 60, // 69: satd.events.v1.NodeEvent.heartbeat:type_name -> satd.events.v1.Heartbeat + 32, // 70: satd.events.v1.NodeEvent.outpoint_spent:type_name -> satd.events.v1.OutpointSpent + 33, // 71: satd.events.v1.NodeEvent.script_matched:type_name -> satd.events.v1.ScriptMatched + 62, // 72: satd.events.v1.NodeEvent.lagged:type_name -> satd.events.v1.Lagged + 35, // 73: satd.events.v1.NodeEvent.txid_matched:type_name -> satd.events.v1.TxidMatched + 36, // 74: satd.events.v1.NodeEvent.txid_replaced:type_name -> satd.events.v1.TxidReplaced + 37, // 75: satd.events.v1.NodeEvent.txid_evicted:type_name -> satd.events.v1.TxidEvicted + 38, // 76: satd.events.v1.NodeEvent.txid_unconfirmed:type_name -> satd.events.v1.TxidUnconfirmed + 39, // 77: satd.events.v1.NodeEvent.txid_depth_reached:type_name -> satd.events.v1.TxidDepthReached + 40, // 78: satd.events.v1.NodeEvent.txid_finalized:type_name -> satd.events.v1.TxidFinalized + 42, // 79: satd.events.v1.NodeEvent.prefix_matched:type_name -> satd.events.v1.PrefixMatched + 63, // 80: satd.events.v1.NodeEvent.set_cursor_result:type_name -> satd.events.v1.SetCursorResult + 66, // 81: satd.events.v1.NodeEvent.set_watch_set_result:type_name -> satd.events.v1.WatchSetResult + 70, // 82: satd.events.v1.NodeEvent.rescan_result:type_name -> satd.events.v1.RescanResult + 73, // 83: satd.events.v1.NodeEvent.rescan_complete:type_name -> satd.events.v1.RescanComplete + 44, // 84: satd.events.v1.NodeEvent.block_tweaks:type_name -> satd.events.v1.BlockTweaks + 43, // 85: satd.events.v1.NodeEvent.silent_payment_matched:type_name -> satd.events.v1.SilentPaymentMatched + 47, // 86: satd.events.v1.NodeEvent.mempool_tweak:type_name -> satd.events.v1.MempoolTweak + 61, // 87: satd.events.v1.NodeEvent.status:type_name -> satd.events.v1.StatusEvent + 69, // 88: satd.events.v1.NodeEvent.watch_add_rejected:type_name -> satd.events.v1.WatchAddRejected + 48, // 89: satd.events.v1.NodeEventStream.Subscribe:input_type -> satd.events.v1.SubscribeRequest + 9, // 90: satd.events.v1.NodeEventStream.Watch:input_type -> satd.events.v1.SubscribeControl + 74, // 91: satd.events.v1.NodeEventStream.Subscribe:output_type -> satd.events.v1.NodeEvent + 74, // 92: satd.events.v1.NodeEventStream.Watch:output_type -> satd.events.v1.NodeEvent + 91, // [91:93] is the sub-list for method output_type + 89, // [89:91] is the sub-list for method input_type + 89, // [89:89] is the sub-list for extension type_name + 89, // [89:89] is the sub-list for extension extendee + 0, // [0:89] is the sub-list for field type_name } func init() { file_satd_events_v1_events_proto_init() } @@ -6187,11 +6570,11 @@ func file_satd_events_v1_events_proto_init() { (*WatchSetResult_Accepted)(nil), (*WatchSetResult_Rejected)(nil), } - file_satd_events_v1_events_proto_msgTypes[60].OneofWrappers = []any{ + file_satd_events_v1_events_proto_msgTypes[61].OneofWrappers = []any{ (*RescanResult_Accepted)(nil), (*RescanResult_Rejected)(nil), } - file_satd_events_v1_events_proto_msgTypes[64].OneofWrappers = []any{ + file_satd_events_v1_events_proto_msgTypes[65].OneofWrappers = []any{ (*NodeEvent_Mempool)(nil), (*NodeEvent_Chain)(nil), (*NodeEvent_Heartbeat)(nil), @@ -6213,14 +6596,15 @@ func file_satd_events_v1_events_proto_init() { (*NodeEvent_SilentPaymentMatched)(nil), (*NodeEvent_MempoolTweak)(nil), (*NodeEvent_Status)(nil), + (*NodeEvent_WatchAddRejected)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_satd_events_v1_events_proto_rawDesc), len(file_satd_events_v1_events_proto_rawDesc)), - NumEnums: 7, - NumMessages: 66, + NumEnums: 9, + NumMessages: 67, NumExtensions: 0, NumServices: 1, }, diff --git a/events/src/grpc.rs b/events/src/grpc.rs index fbdf97d94..309431e2b 100644 --- a/events/src/grpc.rs +++ b/events/src/grpc.rs @@ -1410,6 +1410,11 @@ impl NodeEventStream for NodeEventStreamSvc { // never drops — "exactly one RescanResult per actionable RescanBlocks"). let (rescan_reject_tx, rescan_reject_rx) = tokio::sync::mpsc::channel::(32); + // An incremental Add* the watch-set refused, handed to the outbound task + // for an in-band WatchAddRejected. Same backpressure contract as the + // channels above: blocks on full, never drops. + let (add_reject_tx, mut add_reject_rx) = + tokio::sync::mpsc::channel::(32); // Inbound control reader: applies watch-set mutations + category // changes for the life of the stream against the shared subscription- @@ -1426,6 +1431,7 @@ impl NodeEventStream for NodeEventStreamSvc { let rescan_in_flight = rescan_in_flight.clone(); let rescan_tx = rescan_tx; let rescan_reject_tx = rescan_reject_tx; + let add_reject_tx = add_reject_tx; tokio::spawn(async move { use node::events::CursorRejectReason; use node::events::RescanRejectReason; @@ -1617,16 +1623,23 @@ impl NodeEventStream for NodeEventStreamSvc { } continue; } - let mut guard = watch_set.lock().unwrap_or_else(|p| p.into_inner()); - apply_control( - ctrl, - &handle, - principal.as_ref(), - &category_mask, - &include_raw_tx, - &mut guard, - prefix_bounds, - ); + // Scope the guard so it drops before the send below. + let rejected = { + let mut guard = watch_set.lock().unwrap_or_else(|p| p.into_inner()); + apply_control( + ctrl, + &handle, + principal.as_ref(), + &category_mask, + &include_raw_tx, + &mut guard, + prefix_bounds, + ) + }; + if let Some(r) = rejected { + // Outbound gone (stream tearing down) → nothing to deliver. + let _ = add_reject_tx.send(r).await; + } } // Inbound (control) closed. The watch-set is NOT dropped here — // it belongs to the subscription and drops with the outbound @@ -1780,6 +1793,14 @@ impl NodeEventStream for NodeEventStreamSvc { return; } } + // An incremental add the watch-set refused: emit the in-band + // WatchAddRejected naming the items that are not watched. + Some(rejected) = add_reject_rx.recv() => { + let ev = watch_add_rejected_to_proto(&edge, &rejected); + if tx_out.send(Ok(ev)).await.is_err() { + return; + } + } // Bounded historical rescan (§6.1): scan THIS connection's // watch-set over [from,to] and drain the confirmed matches in // height order, bracketed by a RescanResult ack and a terminal @@ -2060,6 +2081,8 @@ impl NodeEventStream for NodeEventStreamSvc { /// (cross-message), and mints a per-item lease so a later remove frees exactly /// that unit. A rejected add is logged and skipped without tearing down the /// stream. +/// Apply one watch-set control message. Returns the refusal of an incremental +/// `Add*` the watch-set did not register, for the caller to report in-band. fn apply_control( ctrl: pb::SubscribeControl, handle: &node::events::WatchHandle, @@ -2068,18 +2091,17 @@ fn apply_control( include_raw_tx: &AtomicBool, watch_set: &mut WatchSet, prefix_bounds: (u8, u8), -) { +) -> Option { + use crate::watchset::{AddRejectReason, AddRejected, RejectedItems}; use pb::subscribe_control::Msg; let (prefix_min_bits, prefix_max_bits) = prefix_bounds; match ctrl.msg { Some(Msg::AddOutpoints(a)) => { - watch_set.add_outpoints( - principal, - a.outpoints.iter().filter_map(parse_outpoint), - |ops| { + return watch_set + .add_outpoints(principal, a.outpoints.iter().filter_map(parse_outpoint), |ops| { handle.add_outpoints(ops); - }, - ); + }) + .err(); } Some(Msg::AddScripts(a)) => { // Optional per-script `min_value` floors, parallel to `scripthashes`. @@ -2093,6 +2115,12 @@ fn apply_control( scripthashes = a.scripthashes.len(), "AddScripts min_values length mismatch; ignoring add", ); + return Some(AddRejected { + reason: AddRejectReason::Malformed, + items: RejectedItems::Scripts( + a.scripthashes.iter().filter_map(|b| parse_scripthash(b)).collect(), + ), + }); } else { // scripthash → floor (0 when no min_values given). let floors: std::collections::HashMap<[u8; 32], u64> = a @@ -2116,13 +2144,15 @@ fn apply_control( .collect(); handle.add_scripthashes_with_floors(&items); }; - watch_set.add_scripts( - principal, - a.scripthashes.iter().filter_map(|b| parse_scripthash(b)), - "scripts", - apply_floors, - apply_floors, - ); + return watch_set + .add_scripts( + principal, + a.scripthashes.iter().filter_map(|b| parse_scripthash(b)), + "scripts", + apply_floors, + apply_floors, + ) + .err(); } } Some(Msg::RemoveOutpoints(r)) => { @@ -2145,20 +2175,29 @@ fn apply_control( if depths.is_empty() { // Lifecycle watch(es); `auto_close_depth` rides on each (0 = off). let auto_close = a.auto_close_depth; - watch_set.add_transactions(principal, txids, |txids| { - handle.add_txids(txids, auto_close); - }); + return watch_set + .add_transactions(principal, txids, |txids| { + handle.add_txids(txids, auto_close); + }) + .err(); } else if let Some(pairs) = bounded_txid_depth_pairs(&txids, &depths) { // Single-shot depth alarms — one item per (txid × distinct depth). - watch_set.add_tx_depths(principal, pairs, |items| { - handle.add_tx_depths(items); - }); + return watch_set + .add_tx_depths(principal, pairs, |items| { + handle.add_tx_depths(items); + }) + .err(); } else { warn!( target: "events::grpc", txids = txids.len(), depths = depths.len(), "AddTransactions txid×depth product exceeds cap; rejecting message", ); + // The product is too large to echo; the kind still says which add. + return Some(AddRejected { + reason: AddRejectReason::Malformed, + items: RejectedItems::DepthAlarms(Vec::new()), + }); } } Some(Msg::RemoveTransactions(r)) => { @@ -2186,22 +2225,36 @@ fn apply_control( // membership so it can be slid or removed cleanly. Charges one unit // per net-new script. The client advances `start` to slide the window // (gap-limit tracking is client-side); re-asserting reconciles. + let refused = |reason, kept| AddRejected { + reason, + items: RejectedItems::Descriptor { + descriptor: d.descriptor.clone(), + gap_limit: d.gap_limit, + start: d.start, + kept, + }, + }; match crate::descriptor::expand_descriptor(&d.descriptor, d.start, d.gap_limit) { Ok(scripts) => { - watch_set.add_descriptor( - principal, - d.descriptor.clone(), - scripts, - |shs| { - handle.add_scripthashes(shs); - }, - |shs| { - handle.remove_scripthashes(shs); - }, - ); + return watch_set + .add_descriptor( + principal, + d.descriptor.clone(), + scripts, + |shs| { + handle.add_scripthashes(shs); + }, + |shs| { + handle.remove_scripthashes(shs); + }, + ) + .err() + .map(|(reason, kept)| refused(reason, kept)); } Err(e) => { warn!(target: "events::grpc", error = %e, "ignoring invalid descriptor"); + let kept = watch_set.holds_descriptor(&d.descriptor); + return Some(refused(AddRejectReason::Malformed, kept)); } } } @@ -2227,9 +2280,11 @@ fn apply_control( ) }) .collect(); - watch_set.add_prefixes(principal, items, |keys| { - handle.add_prefixes(keys); - }); + return watch_set + .add_prefixes(principal, items, |keys| { + handle.add_prefixes(keys); + }) + .err(); } Some(Msg::RemoveScriptPrefixes(r)) => { let keys: Vec<(u8, u32)> = r @@ -2323,9 +2378,11 @@ fn apply_control( t.scan_secret.zeroize(); } if !targets.is_empty() { - watch_set.add_silent_payments(principal, targets, |ts| { - handle.add_silent_payments(ts); - }); + return watch_set + .add_silent_payments(principal, targets, |ts| { + handle.add_silent_payments(ts); + }) + .err(); } } Some(Msg::RemoveSilentPayments(r)) => { @@ -2343,6 +2400,7 @@ fn apply_control( } None => {} } + None } /// Build a [`DesiredWatchSet`] from a `SetWatchSet` snapshot: expand each @@ -2483,6 +2541,89 @@ fn watch_set_result_to_proto( } } +/// Render a refused incremental add as the in-band `WatchAddRejected` node +/// event. Hashes and txids go out in internal byte order, as everywhere else on +/// this wire; a prefix goes out masked to its `bits`. +fn watch_add_rejected_to_proto( + edge: &node::events::EdgeIdentity, + rejected: &crate::watchset::AddRejected, +) -> pb::NodeEvent { + use crate::watchset::{AddRejectReason, RejectedItems}; + use bitcoin::hashes::Hash; + use pb::watch_add_rejected::{Kind, Reason}; + let mut r = pb::WatchAddRejected::default(); + match rejected.reason { + AddRejectReason::QuotaExceeded { required, held, quota } => { + r.reason = Reason::QuotaExceeded as i32; + r.required = required; + r.held = held; + r.quota = quota; + } + AddRejectReason::RateLimited { retry_after_secs } => { + r.reason = Reason::RateLimited as i32; + r.retry_after_secs = retry_after_secs; + } + AddRejectReason::CapExceeded { requested, limit } => { + r.reason = Reason::CapExceeded as i32; + r.required = requested; + r.quota = limit; + } + AddRejectReason::PermissionDenied => r.reason = Reason::PermissionDenied as i32, + AddRejectReason::Malformed => r.reason = Reason::Malformed as i32, + } + match &rejected.items { + RejectedItems::Scripts(shs) => { + r.kind = Kind::Scripts as i32; + r.scripthashes = shs.iter().map(|sh| sh.to_vec()).collect(); + } + RejectedItems::Outpoints(ops) => { + r.kind = Kind::Outpoints as i32; + r.outpoints = ops + .iter() + .map(|op| pb::Outpoint { txid: op.txid.to_byte_array().to_vec(), vout: op.vout }) + .collect(); + } + RejectedItems::Transactions(txids) => { + r.kind = Kind::Transactions as i32; + r.txids = txids.iter().map(|t| t.to_byte_array().to_vec()).collect(); + } + RejectedItems::DepthAlarms(pairs) => { + r.kind = Kind::DepthAlarms as i32; + r.depth_alarms = pairs + .iter() + .map(|(t, depth)| pb::WatchDepthAlarm { txid: t.to_byte_array().to_vec(), depth: *depth }) + .collect(); + } + RejectedItems::Descriptor { descriptor, gap_limit, start, kept } => { + r.kind = Kind::Descriptor as i32; + r.descriptor = descriptor.clone(); + r.gap_limit = *gap_limit; + r.start = *start; + r.descriptor_kept = *kept; + } + RejectedItems::Prefixes(keys) => { + r.kind = Kind::ScriptPrefixes as i32; + r.prefixes = keys + .iter() + .map(|(bits, masked)| pb::ScriptPrefix { + prefix: masked.to_be_bytes()[..usize::from(*bits).div_ceil(8)].to_vec(), + bits: u32::from(*bits), + }) + .collect(); + } + RejectedItems::SilentPayments(ids) => { + r.kind = Kind::SilentPayments as i32; + r.scan_pubkeys = ids.iter().map(|id| id.to_vec()).collect(); + } + } + pb::NodeEvent { + schema_version: node::events::SCHEMA_VERSION, + stamp: Some(replay_stamp(edge)), + cursor: None, + body: Some(pb::node_event::Body::WatchAddRejected(r)), + } +} + /// Build the in-band `RescanResult{Accepted}` node event — the ack emitted /// ahead of a bounded historical rescan's matches (§6.1). Carries no cursor: a /// rescan is a side query and does not advance the durable forward cursor. @@ -5036,6 +5177,7 @@ mod tests { pb::node_event::Body::SilentPaymentMatched(_) => "silent_payment_matched", pb::node_event::Body::MempoolTweak(_) => "mempool_tweak", pb::node_event::Body::Status(_) => "status", + pb::node_event::Body::WatchAddRejected(_) => "watch_add_rejected", }) .collect(); assert!( @@ -6708,6 +6850,177 @@ mod tests { let _ = shutdown_tx.send(true); } + /// An incremental add the quota cannot hold is answered in-band with a + /// `WatchAddRejected` naming the refused items, over the real authenticated + /// wire path, and the stream stays up. An add that fits produces no event: + /// the next event after it is the following add's refusal, whose `held` + /// counts the unit the accepted add took. + #[tokio::test] + async fn watch_over_quota_add_is_rejected_in_band() { + use std::io::Write; + // plaintext "grpc-addreject-token" → this sha256. + const TOKEN: &str = "grpc-addreject-token"; + const TOKEN_SHA256: &str = + "b2e84d3711de9d1913e2d74a16a487fe4400a7f855e981b3e740a9477dd31700"; + let dir = tempfile::tempdir().unwrap(); + let toml = format!( + "version = 1\n\ + [[token]]\nid=\"q1\"\nhash=\"sha256:{TOKEN_SHA256}\"\n\ + capabilities=[\"stream:subscribe\",\"stream:watch\"]\n\ + watch_quota=1\n" + ); + let path = dir.path().join("auth.toml"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(toml.as_bytes()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + // The node meters quotas with real accounting; `load` alone is unlimited. + let store = Arc::new( + satd_auth::TokenStore::load(&path) + .unwrap() + .with_accounting(Arc::new(satd_auth::LocalAccounting::new())), + ); + + let publisher = EventPublisher::new(edge(), 64); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let registry = Arc::new(node::events::WatchRegistry::new()); + let sink = GrpcEventSink::bind( + "127.0.0.1:0", + false, + publisher.clone(), + GrpcLimits::default(), + Some(store), + Some(Arc::new(MockBlocks { tip: 5 })), + None, + Some(registry.clone()), + None, + None, + ) + .await + .expect("bind"); + let actual = sink.local_addr().unwrap(); + publisher.attach_sinks(vec![Box::new(sink)], shutdown_rx.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + let mut client = + pb::node_event_stream_client::NodeEventStreamClient::connect(format!( + "http://{actual}" + )) + .await + .expect("connect"); + + let add = |shs: &[u8]| pb::SubscribeControl { + msg: Some(pb::subscribe_control::Msg::AddScripts(pb::AddScripts { + scripthashes: shs.iter().map(|b| vec![*b; 32]).collect(), + min_values: vec![], + })), + }; + let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::(8); + // Two scripts against a one-unit quota: refused whole. + ctrl_tx.send(add(&[0x01, 0x02])).await.unwrap(); + let mut req = tonic::Request::new(ReceiverStream::new(ctrl_rx)); + req.metadata_mut() + .insert("authorization", format!("Bearer {TOKEN}").parse().unwrap()); + let mut stream = client.watch(req).await.expect("watch").into_inner(); + + let next_rejection = |ev: pb::NodeEvent| match ev.body { + Some(pb::node_event::Body::WatchAddRejected(r)) => r, + other => panic!("expected WatchAddRejected, got {other:?}"), + }; + let ev = tokio::time::timeout(Duration::from_secs(3), stream.message()) + .await + .expect("timeout") + .expect("transport") + .expect("stream ended"); + let r = next_rejection(ev); + assert_eq!(r.kind, pb::watch_add_rejected::Kind::Scripts as i32); + assert_eq!(r.reason, pb::watch_add_rejected::Reason::QuotaExceeded as i32); + assert_eq!((r.required, r.held, r.quota), (2, 0, 1)); + assert_eq!(r.scripthashes, vec![vec![0x01; 32], vec![0x02; 32]]); + assert!(!registry.has_watchers(), "a refused add registers nothing"); + + // One script fits: no event. Then one more is refused with held = 1. + ctrl_tx.send(add(&[0x03])).await.unwrap(); + ctrl_tx.send(add(&[0x04])).await.unwrap(); + let ev = tokio::time::timeout(Duration::from_secs(3), stream.message()) + .await + .expect("timeout") + .expect("transport") + .expect("stream ended"); + let r = next_rejection(ev); + assert_eq!((r.required, r.held, r.quota), (1, 1, 1), "the accepted add holds the unit"); + assert_eq!(r.scripthashes, vec![vec![0x04; 32]]); + assert!(registry.has_watchers(), "the add that fit is watched"); + + drop(ctrl_tx); + let _ = shutdown_tx.send(true); + } + + /// Every kind of refused add encodes its items the way the client sends + /// them: txids in internal byte order, a prefix masked and cut to its bits, + /// a descriptor with the window it asked for, silent-payment targets by scan + /// pubkey. + #[test] + fn watch_add_rejected_encodes_each_kind() { + use crate::watchset::{AddRejectReason, AddRejected, RejectedItems}; + use bitcoin::hashes::Hash; + use pb::watch_add_rejected::{Kind, Reason}; + let txid = Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_byte_array([0xab; 32])); + let render = |reason, items| match watch_add_rejected_to_proto( + &edge(), + &AddRejected { reason, items }, + ) + .body + { + Some(pb::node_event::Body::WatchAddRejected(r)) => r, + other => panic!("expected WatchAddRejected, got {other:?}"), + }; + + let r = render( + AddRejectReason::RateLimited { retry_after_secs: 7 }, + RejectedItems::Outpoints(vec![bitcoin::OutPoint { txid, vout: 3 }]), + ); + assert_eq!((r.kind, r.reason), (Kind::Outpoints as i32, Reason::RateLimited as i32)); + assert_eq!(r.retry_after_secs, 7); + assert_eq!(r.outpoints, vec![pb::Outpoint { txid: vec![0xab; 32], vout: 3 }]); + + let r = render(AddRejectReason::PermissionDenied, RejectedItems::Transactions(vec![txid])); + assert_eq!((r.kind, r.reason), (Kind::Transactions as i32, Reason::PermissionDenied as i32)); + assert_eq!(r.txids, vec![vec![0xab; 32]]); + + let r = render( + AddRejectReason::QuotaExceeded { required: 2, held: 5, quota: 6 }, + RejectedItems::DepthAlarms(vec![(txid, 6)]), + ); + assert_eq!((r.kind, r.required, r.held, r.quota), (Kind::DepthAlarms as i32, 2, 5, 6)); + assert_eq!(r.depth_alarms, vec![pb::WatchDepthAlarm { txid: vec![0xab; 32], depth: 6 }]); + + let r = render( + AddRejectReason::Malformed, + RejectedItems::Descriptor { descriptor: "wpkh(x)".into(), gap_limit: 20, start: 40, kept: true }, + ); + assert_eq!((r.kind, r.reason), (Kind::Descriptor as i32, Reason::Malformed as i32)); + assert_eq!((r.descriptor.as_str(), r.gap_limit, r.start, r.descriptor_kept), ("wpkh(x)", 20, 40, true)); + + // 12 bits → 2 bytes, low 4 bits of the second byte masked off. + let (key, _) = crate::watchset::parse_prefix(&[0xab, 0xcd], 12, 8, 32).unwrap(); + let r = render( + AddRejectReason::CapExceeded { requested: 3, limit: 2 }, + RejectedItems::Prefixes(vec![key]), + ); + assert_eq!((r.kind, r.required, r.quota), (Kind::ScriptPrefixes as i32, 3, 2)); + assert_eq!(r.prefixes, vec![pb::ScriptPrefix { prefix: vec![0xab, 0xc0], bits: 12 }]); + + let r = render( + AddRejectReason::CapExceeded { requested: 17, limit: 16 }, + RejectedItems::SilentPayments(vec![[0x02; 33]]), + ); + assert_eq!(r.kind, Kind::SilentPayments as i32); + assert_eq!(r.scan_pubkeys, vec![vec![0x02; 33]]); + } + /// A `SetCursor` that arrives while a previous re-anchor is **actively /// draining** (not merely queued) must be rejected `ConcurrentReanchor`, not /// queued and serviced late. The capacity-1 channel alone does not cover diff --git a/events/src/watchset.rs b/events/src/watchset.rs index e28c7fb5c..87a62e858 100644 --- a/events/src/watchset.rs +++ b/events/src/watchset.rs @@ -21,8 +21,9 @@ //! reserved in one [`Principal::acquire_watch`] call, then split into per-item //! leases via [`WatchLease::split_off_one`] (which moves units without touching //! the store). If the reservation does not fit the quota, none of the add's -//! items are registered — the protocol has no per-item ack, so a partial add -//! would be a silent partial failure. +//! net-new items are registered, and the add returns an [`AddRejected`] naming +//! them, which the carrier reports in-band (`WatchAddRejected`). A partial add +//! would leave the client unable to tell which items it holds. //! //! A `WatchSet` is held behind the subscription-scoped `Arc>` shared //! by the inbound control reader and the outbound stream, so the quota is tied @@ -104,6 +105,85 @@ pub(crate) type DescriptorWindow = Vec<(u32, u32, Scripthash)>; /// `u32` is the top 32 bits of `sha256(spk)` masked to `bits`. type PrefixKey = (u8, u32); +/// Why an incremental `Add*` registered none of its net-new items. The carrier +/// reports it in-band as a `WatchAddRejected` event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AddRejectReason { + /// The net-new items cost `required` units; the principal already holds + /// `held` of its `quota`. + QuotaExceeded { required: u64, held: u64, quota: u64 }, + /// The per-principal add rate limit is spent. + RateLimited { retry_after_secs: u32 }, + /// A per-connection cap: the add would bring the count to `requested`, past + /// `limit`. + CapExceeded { requested: u64, limit: u64 }, + /// The principal lacks the `stream:watch` capability. + PermissionDenied, + /// The carrier could not apply the message as a whole (a `min_values` list + /// that is not parallel to its scripthashes, an invalid descriptor, a txid × + /// depth product over [`MAX_TXID_DEPTH_PAIRS`]). + Malformed, +} + +/// The items a rejected add named, in registry form. None of them is watched. +/// Only the add's net-new items appear: items it re-asserted were already +/// watched and stay watched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RejectedItems { + Scripts(Vec), + Outpoints(Vec), + Transactions(Vec), + DepthAlarms(Vec<(Txid, u32)>), + /// The descriptor and the window the add asked for. `kept` is true when an + /// earlier window of the same descriptor stays watched. + Descriptor { descriptor: String, gap_limit: u32, start: u32, kept: bool }, + Prefixes(Vec), + /// Silent-payment targets by identity `b_scan·G`, never the scan secret. + SilentPayments(Vec<[u8; 33]>), +} + +/// An incremental add the watch-set refused, for the carrier to report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AddRejected { + pub reason: AddRejectReason, + pub items: RejectedItems, +} + +/// Map a quota-store refusal to the reason the client is told. +fn watch_reject_reason(reject: satd_auth::WatchReject) -> AddRejectReason { + match reject { + satd_auth::WatchReject::MissingCapability(_) => AddRejectReason::PermissionDenied, + satd_auth::WatchReject::QuotaExceeded(q) => AddRejectReason::QuotaExceeded { + required: q.requested, + held: q.current, + quota: q.max, + }, + } +} + +/// The per-connection entry cap an incremental add is checked against: the +/// watch-set's size across all kinds, and its cap (`0` = none). +#[derive(Debug, Clone, Copy)] +struct Room { + len: usize, + cap: usize, +} + +impl Room { + /// Refuse an add that brings `adding` new entries to a set already at its + /// cap. A set below the cap takes the whole add, so one message may + /// overshoot by its own size, itself bounded by the inbound frame cap. + fn check(self, adding: usize) -> Result<(), AddRejectReason> { + if self.cap != 0 && adding > 0 && self.len >= self.cap { + return Err(AddRejectReason::CapExceeded { + requested: (self.len + adding) as u64, + limit: self.cap as u64, + }); + } + Ok(()) + } +} + /// Cap on the coarseness multiplier's shift. A prefix `bits` below `K_MAX` /// charges `1 << (K_MAX - bits)` units — honest bandwidth pricing (a coarser /// bucket delivers proportionally more) — but capped here so a very coarse @@ -306,9 +386,27 @@ pub(crate) struct WatchSet { /// node-side matcher registry (§4.3) — never copied into the carrier's /// bookkeeping. silent_payments: HashMap<[u8; 33], Option>, + /// Per-connection entry cap on incremental adds (`0` = none). WS sets + /// `streamwsmaxsubscriptions`; gRPC, whose bound is the quota, has none. + entry_cap: usize, } impl WatchSet { + /// A watch-set whose incremental adds are refused once it holds `entry_cap` + /// entries across all kinds (`0` = no cap). + pub(crate) fn with_entry_cap(entry_cap: usize) -> Self { + Self { entry_cap, ..Self::default() } + } + + /// The per-connection entry cap (`0` = none). + pub(crate) fn entry_cap(&self) -> usize { + self.entry_cap + } + + fn room(&self) -> Room { + Room { len: self.len(), cap: self.entry_cap } + } + /// Add outpoints, charging the quota only for items not already watched and /// registering the net-new ones via `register`. All-or-nothing per call. pub(crate) fn add_outpoints( @@ -316,8 +414,10 @@ impl WatchSet { principal: Option<&satd_auth::Principal>, incoming: impl IntoIterator, register: impl FnOnce(&[OutPoint]), - ) { - add_items(&mut self.outpoints, principal, incoming, "outpoints", register, |_| {}); + ) -> Result<(), AddRejected> { + let room = self.room(); + add_items(&mut self.outpoints, principal, incoming, "outpoints", room, register, |_| {}) + .map_err(|(reason, items)| AddRejected { reason, items: RejectedItems::Outpoints(items) }) } /// Add **directly-watched** scripthashes (an `AddScripts` control message). @@ -338,11 +438,14 @@ impl WatchSet { kind: &'static str, register: impl FnOnce(&[Scripthash]), reassert: impl FnOnce(&[Scripthash]), - ) { + ) -> Result<(), AddRejected> { let items: Vec = incoming.into_iter().collect(); // The registry/lease/floor handling is unchanged: `add_items` charges // net-new (scripts not already in `scripts`) and refreshes re-asserts. - add_items(&mut self.scripts, principal, items.iter().copied(), kind, register, reassert); + let room = self.room(); + let added = + add_items(&mut self.scripts, principal, items.iter().copied(), kind, room, register, reassert) + .map_err(|(reason, items)| AddRejected { reason, items: RejectedItems::Scripts(items) }); // Reconcile direct ownership for whatever is now watched: every script // that ended up in `scripts` (net-new committed, or already held) and is // not yet a direct owner becomes one. A net-new that failed the quota is @@ -352,6 +455,7 @@ impl WatchSet { *self.script_owners.entry(*s).or_insert(0) += 1; } } + added } /// Remove **direct** ownership of scripthashes (a `RemoveScripts` control @@ -381,7 +485,8 @@ impl WatchSet { /// entered are added. `register` / `reassert` / `unregister` mirror the /// other paths. All-or-nothing on quota: if the net-new scripts do not fit, /// the whole (re)assert is rejected and the descriptor's membership is left - /// unchanged. + /// unchanged. On rejection the `bool` is true when the descriptor was already + /// held, so its earlier window stays watched. pub(crate) fn add_descriptor( &mut self, principal: Option<&satd_auth::Principal>, @@ -389,7 +494,7 @@ impl WatchSet { derived: impl IntoIterator, register: impl FnOnce(&[Scripthash]), unregister: impl FnOnce(&[Scripthash]), - ) { + ) -> Result<(), (AddRejectReason, bool)> { // Dedup the new membership, preserving first-seen order. `new_coords` runs // parallel to `new`, carrying each scripthash's `(branch, index)` from the // expansion (first occurrence wins if a script recurs across branches). @@ -415,7 +520,13 @@ impl WatchSet { cap = MAX_DESCRIPTORS_PER_CONNECTION, "descriptor count cap reached; rejecting new descriptor", ); - return; + return Err(( + AddRejectReason::CapExceeded { + requested: self.descriptors.len() as u64 + 1, + limit: MAX_DESCRIPTORS_PER_CONNECTION as u64, + }, + false, + )); } let old: Vec = self.descriptors.get(&descriptor).cloned().unwrap_or_default(); @@ -429,11 +540,14 @@ impl WatchSet { to_add.iter().copied().filter(|s| !self.scripts.contains_key(s)).collect(); if !net_new.is_empty() { - if !reserve_scripts(&mut self.scripts, principal, &net_new, "descriptor", register) { - // Quota/rate rejected the net-new batch: change nothing + let room = self.room(); + if let Err(reason) = + reserve_scripts(&mut self.scripts, principal, &net_new, "descriptor", room, register) + { + // Quota/rate/cap rejected the net-new batch: change nothing // (membership, ownership, and the prior window all stay as they // were). - return; + return Err((reason, !is_new_descriptor)); } } else if !to_add.is_empty() || old_set.iter().any(|s| !new_set.contains(s)) { // Membership changed (scripts entered the window from another owner, @@ -451,7 +565,7 @@ impl WatchSet { retry_after_secs, "descriptor re-assert rate-limited; skipping", ); - return; + return Err((AddRejectReason::RateLimited { retry_after_secs }, !is_new_descriptor)); } } // Commit ownership for every script that gained this descriptor. @@ -479,6 +593,7 @@ impl WatchSet { } self.descriptors.insert(descriptor, new); + Ok(()) } /// Remove a descriptor entirely (a `RemoveDescriptor` control message), @@ -835,6 +950,11 @@ impl WatchSet { /// the exact BIP-389 branch and absolute index the server derived it at, so a /// client needs no positional arithmetic. Empty for a directly-watched /// (non-descriptor) script. The carrier attaches this to `ScriptMatched`. + /// Whether `descriptor` has a window watched on this connection. + pub(crate) fn holds_descriptor(&self, descriptor: &str) -> bool { + self.descriptors.contains_key(descriptor) + } + pub(crate) fn descriptor_attribution( &self, scripthash: &Scripthash, @@ -872,8 +992,10 @@ impl WatchSet { principal: Option<&satd_auth::Principal>, incoming: impl IntoIterator, register: impl FnOnce(&[Txid]), - ) { - add_items(&mut self.txids, principal, incoming, "transactions", register, |_| {}); + ) -> Result<(), AddRejected> { + let room = self.room(); + add_items(&mut self.txids, principal, incoming, "transactions", room, register, |_| {}) + .map_err(|(reason, items)| AddRejected { reason, items: RejectedItems::Transactions(items) }) } /// Remove txids, releasing each removed item's quota unit. @@ -892,8 +1014,10 @@ impl WatchSet { principal: Option<&satd_auth::Principal>, incoming: impl IntoIterator, register: impl FnOnce(&[(Txid, u32)]), - ) { - add_items(&mut self.tx_depths, principal, incoming, "tx_depths", register, |_| {}); + ) -> Result<(), AddRejected> { + let room = self.room(); + add_items(&mut self.tx_depths, principal, incoming, "tx_depths", room, register, |_| {}) + .map_err(|(reason, items)| AddRejected { reason, items: RejectedItems::DepthAlarms(items) }) } /// Remove depth alarms, releasing each removed pair's quota unit. @@ -913,20 +1037,23 @@ impl WatchSet { principal: Option<&satd_auth::Principal>, incoming: impl IntoIterator, register: impl FnOnce(&[PrefixKey]), - ) { + ) -> Result<(), AddRejected> { // Collect the (key → cost) of net-new buckets up front so the priced // charge can read each item's cost. `add_items_priced` re-derives the // cost via the closure; a HashMap lookup keeps the two in lockstep. let costs: HashMap = incoming.into_iter().collect(); + let room = self.room(); add_items_priced( &mut self.prefixes, principal, costs.keys().copied(), |k| costs.get(k).copied().unwrap_or(1), "prefixes", + room, register, |_| {}, - ); + ) + .map_err(|(reason, items)| AddRejected { reason, items: RejectedItems::Prefixes(items) }) } /// Remove prefix watches, releasing each removed bucket's (multi-unit) lease. @@ -951,13 +1078,13 @@ impl WatchSet { /// push the retained count over the cap, the whole add is shed (like a /// descriptor over its cap). All-or-nothing on quota. `register` receives the /// net-new targets AND the re-asserted ones so the caller applies label - /// updates in the matcher. + /// updates in the matcher. A rejection names the net-new targets by identity. pub(crate) fn add_silent_payments( &mut self, principal: Option<&satd_auth::Principal>, targets: Vec, register: impl FnOnce(&[node::events::SpWatchTarget]), - ) { + ) -> Result<(), AddRejected> { // Partition into net-new (identity not yet held) and re-asserts (held; // may carry a changed label set). Dedup within the message. let mut seen = HashSet::new(); @@ -975,8 +1102,12 @@ impl WatchSet { } } if net_new.is_empty() && reassert.is_empty() { - return; + return Ok(()); } + let rejected = |reason, net_new: &[node::events::SpWatchTarget]| AddRejected { + reason, + items: RejectedItems::SilentPayments(net_new.iter().map(|t| t.scan_pubkey()).collect()), + }; // Per-connection SP cap: only net-new grows the retained set. if self.silent_payments.len() + net_new.len() > MAX_SP_TARGETS_PER_CONNECTION { warn!( @@ -986,7 +1117,19 @@ impl WatchSet { cap = MAX_SP_TARGETS_PER_CONNECTION, "silent-payment target cap exceeded; skipping add", ); - return; + let reason = AddRejectReason::CapExceeded { + requested: (self.silent_payments.len() + net_new.len()) as u64, + limit: MAX_SP_TARGETS_PER_CONNECTION as u64, + }; + return Err(rejected(reason, &net_new)); + } + if let Err(reason) = self.room().check(net_new.len()) { + warn!( + target: "events::watchset", + kind = "silent_payments", + "watch-set at per-connection entry cap; skipping add", + ); + return Err(rejected(reason, &net_new)); } // Per-add rate limit (mirrors `add_items_priced`): one token per // effective add/update, after the empty short-circuit so a fully-empty @@ -1000,8 +1143,9 @@ impl WatchSet { retry_after_secs, "watch add rate-limited; skipping", ); - return; + return Err(rejected(AddRejectReason::RateLimited { retry_after_secs }, &net_new)); } + let new_ids: Vec<[u8; 33]> = net_new.iter().map(|t| t.scan_pubkey()).collect(); // Register net-new first (indices `[0, n_new)`) then the re-asserts, and // call `register` (an `FnOnce`) exactly once with the final slice. let n_new = net_new.len(); @@ -1013,7 +1157,7 @@ impl WatchSet { // free. `acquire_watch` only when there is something to charge. let batch = if n_new > 0 { match p.acquire_watch(n_new as u64) { - Ok(b) => Some(b), + Ok(b) => Ok(Some(b)), Err(reject) => { warn!( target: "events::watchset", @@ -1021,14 +1165,14 @@ impl WatchSet { reject = ?reject, "watch add rejected (capability or quota)", ); - None + Err(watch_reject_reason(reject)) } } } else { - None + Ok(None) }; match batch { - Some(mut b) => { + Ok(Some(mut b)) => { register(&to_register); for t in to_register.iter().take(n_new) { let lease = b.split_off(1); @@ -1038,10 +1182,19 @@ impl WatchSet { ); self.silent_payments.insert(t.scan_pubkey(), lease); } + Ok(()) + } + // Nothing net-new: apply the re-asserted label updates. + Ok(None) => { + register(&to_register[n_new..]); + Ok(()) + } + // Quota denied: the re-asserted label updates still apply; + // nothing new is retained. + Err(reason) => { + register(&to_register[n_new..]); + Err(AddRejected { reason, items: RejectedItems::SilentPayments(new_ids) }) } - // Nothing net-new (n_new == 0) or quota denied: apply only - // the re-asserted label updates; retain nothing new. - None => register(&to_register[n_new..]), } } // Auth disabled (loopback trust): unlimited, no lease. @@ -1050,6 +1203,7 @@ impl WatchSet { for t in to_register.iter().take(n_new) { self.silent_payments.insert(t.scan_pubkey(), None); } + Ok(()) } } } @@ -1077,32 +1231,40 @@ impl WatchSet { } } +/// An add's refusal and the net-new items it refused, in registry form. +type Refused = (AddRejectReason, Vec); + fn add_items( held: &mut HashMap>, principal: Option<&satd_auth::Principal>, incoming: impl IntoIterator, kind: &'static str, + room: Room, register: impl FnOnce(&[T]), reassert: impl FnOnce(&[T]), -) { +) -> Result<(), Refused> { // The common case: every item costs exactly one unit. - add_items_priced(held, principal, incoming, |_| 1, kind, register, reassert); + add_items_priced(held, principal, incoming, |_| 1, kind, room, register, reassert) } /// Generalization of [`add_items`] where each item carries its own quota cost /// (`cost`). The whole net-new batch is reserved atomically as `sum(cost)` units, /// then split into per-item leases via [`WatchLease::split_off`], so a removal /// returns exactly that item's units. Used by the coarseness-priced prefix add; -/// `add_items` is the `cost = 1` specialization. +/// `add_items` is the `cost = 1` specialization. A refusal returns the net-new +/// items, none of which is registered; re-asserted items are refreshed either +/// way. +#[allow(clippy::too_many_arguments)] // the shared add path's knobs stay unbundled fn add_items_priced( held: &mut HashMap>, principal: Option<&satd_auth::Principal>, incoming: impl IntoIterator, cost: impl Fn(&T) -> u64, kind: &'static str, + room: Room, register: impl FnOnce(&[T]), reassert: impl FnOnce(&[T]), -) { +) -> Result<(), Refused> { // Partition `incoming` into net-new items (not yet watched) and re-asserted // items (already watched). Both are deduped within this message via `seen`. let mut seen = HashSet::new(); @@ -1132,7 +1294,14 @@ fn add_items_priced( if net_new.is_empty() { // No new watches to charge — re-assert-only or empty add. The metadata // refresh above (if any) has already run. - return; + return Ok(()); + } + + // Per-connection entry cap (WS `streamwsmaxsubscriptions`). Checked before + // the rate limit so a capped add does not spend a token. + if let Err(reason) = room.check(net_new.len()) { + warn!(target: "events::watchset", kind, "watch-set at per-connection entry cap; skipping add"); + return Err((reason, net_new)); } // Per-add rate limit (C4): bound the RATE of EFFECTIVE watch-adds — those @@ -1144,8 +1313,8 @@ fn add_items_priced( // operator should size the policy with headroom for the expected add // cadence — e.g. a descriptor sliding window spends one token per // AddDescriptor slide. Operator/loopback and no-policy principals always - // Allow. An over-budget add is shed without tearing down the stream — no - // per-message ack, same posture as the quota-reject path below. + // Allow. An over-budget add is refused without tearing down the stream, and + // the carrier reports it in-band like the quota-reject path below. if let Some(p) = principal && let satd_auth::RateDecision::Throttle { retry_after_secs } = p.check_rate() { @@ -1155,7 +1324,7 @@ fn add_items_priced( retry_after_secs, "watch add rate-limited; skipping", ); - return; + return Err((AddRejectReason::RateLimited { retry_after_secs }, net_new)); } let total: u64 = net_new.iter().map(&cost).sum(); match principal { @@ -1178,6 +1347,7 @@ fn add_items_priced( ); held.insert(it, lease); } + Ok(()) } Err(reject) => { warn!( @@ -1186,6 +1356,7 @@ fn add_items_priced( reject = ?reject, "watch add rejected (capability or quota)", ); + Err((watch_reject_reason(reject), net_new)) } }, // Auth disabled (loopback trust): unlimited, no lease. @@ -1194,25 +1365,31 @@ fn add_items_priced( for it in net_new { held.insert(it, None); } + Ok(()) } } } /// Reserve quota for a batch of net-new scripthashes (one unit each), all-or- /// nothing, inserting each with its split lease and calling `register` on -/// success. Returns whether the batch was committed (always `true` when auth is -/// disabled or the batch is empty). Mirrors the net-new arm of -/// [`add_items_priced`], but *reports* success so a descriptor (re)assert can -/// stay atomic — rejecting the whole window rather than partially registering. +/// success. Returns why the batch was refused (it always commits when auth is +/// disabled and no entry cap applies, or the batch is empty). Mirrors the +/// net-new arm of [`add_items_priced`] so a descriptor (re)assert can stay +/// atomic — rejecting the whole window rather than partially registering. fn reserve_scripts( held: &mut HashMap>, principal: Option<&satd_auth::Principal>, net_new: &[Scripthash], kind: &'static str, + room: Room, register: impl FnOnce(&[Scripthash]), -) -> bool { +) -> Result<(), AddRejectReason> { if net_new.is_empty() { - return true; + return Ok(()); + } + if let Err(reason) = room.check(net_new.len()) { + warn!(target: "events::watchset", kind, "watch-set at per-connection entry cap; skipping add"); + return Err(reason); } // Per-add rate limit (C4): one effective add = one token, checked after the // empty short-circuit so a no-op cannot burn the bucket. @@ -1225,7 +1402,7 @@ fn reserve_scripts( retry_after_secs, "watch add rate-limited; skipping", ); - return false; + return Err(AddRejectReason::RateLimited { retry_after_secs }); } match principal { Some(p) => match p.acquire_watch(net_new.len() as u64) { @@ -1239,7 +1416,7 @@ fn reserve_scripts( ); held.insert(*s, lease); } - true + Ok(()) } Err(reject) => { warn!( @@ -1248,7 +1425,7 @@ fn reserve_scripts( reject = ?reject, "watch add rejected (capability or quota)", ); - false + Err(watch_reject_reason(reject)) } }, // Auth disabled (loopback trust): unlimited, no lease. @@ -1257,7 +1434,7 @@ fn reserve_scripts( for s in net_new { held.insert(*s, None); } - true + Ok(()) } } } @@ -1318,7 +1495,7 @@ mod tests { let mut registered = 0; ws.add_outpoints(Some(&p), [op(1, 0), op(2, 0), op(3, 0)], |items| { registered = items.len(); - }); + }).unwrap(); assert_eq!(registered, 3); assert_eq!(q.current("tenant"), 3, "three items charged 3 units"); assert_eq!(ws.len(), 3); @@ -1337,14 +1514,14 @@ mod tests { let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_outpoints(Some(&p), [op(1, 0), op(2, 0)], |_| {}); + ws.add_outpoints(Some(&p), [op(1, 0), op(2, 0)], |_| {}).unwrap(); assert_eq!(q.current("tenant"), 2); // A SEPARATE message re-asserts op(1) and adds op(3): only op(3) is new. let mut registered = Vec::new(); ws.add_outpoints(Some(&p), [op(1, 0), op(3, 0)], |items| { registered = items.to_vec(); - }); + }).unwrap(); assert_eq!(registered, vec![op(3, 0)], "only the net-new item registers"); assert_eq!(q.current("tenant"), 3, "the re-asserted item is not double-charged"); } @@ -1373,7 +1550,7 @@ mod tests { let mut ws = WatchSet::default(); let mut net_new = Vec::new(); - ws.add_scripts(Some(&p), [sh(1), sh(2)], "scripts", |s| net_new = s.to_vec(), |_| {}); + ws.add_scripts(Some(&p), [sh(1), sh(2)], "scripts", |s| net_new = s.to_vec(), |_| {}).unwrap(); assert_eq!(net_new, vec![sh(1), sh(2)], "first add registers both as net-new"); assert_eq!(q.current("tenant"), 2); @@ -1387,7 +1564,7 @@ mod tests { "scripts", |s| net_new2 = s.to_vec(), |s| reasserted = s.to_vec(), - ); + ).unwrap(); assert_eq!(net_new2, vec![sh(3)], "only the new script registers"); assert_eq!(reasserted, vec![sh(1)], "the held script is surfaced for refresh"); assert_eq!(q.current("tenant"), 3, "re-assert charges no extra quota"); @@ -1401,7 +1578,7 @@ mod tests { "scripts", |_| net_new3 = true, |s| reasserted3 = s.to_vec(), - ); + ).unwrap(); assert!(!net_new3, "no net-new registration on a re-assert-only add"); assert_eq!(reasserted3, vec![sh(1), sh(2)], "both held scripts are surfaced"); assert_eq!(q.current("tenant"), 3, "re-assert-only add charges nothing"); @@ -1423,11 +1600,11 @@ mod tests { ); let mut ws = WatchSet::default(); - ws.add_scripts(Some(&p), [sh(1)], "scripts", |_| {}, |_| {}); + ws.add_scripts(Some(&p), [sh(1)], "scripts", |_| {}, |_| {}).unwrap(); // Bucket now empty. Re-assert sh(1): a net-new add here would be // throttled, but a re-assert must bypass the rate limiter entirely. let mut reasserted = Vec::new(); - ws.add_scripts(Some(&p), [sh(1)], "scripts", |_| {}, |s| reasserted = s.to_vec()); + ws.add_scripts(Some(&p), [sh(1)], "scripts", |_| {}, |s| reasserted = s.to_vec()).unwrap(); assert_eq!(reasserted, vec![sh(1)], "re-assert fires even with an empty rate bucket"); } @@ -1439,10 +1616,81 @@ mod tests { // Three net-new items but quota is 2 → the whole add is rejected. let mut registered = false; - ws.add_outpoints(Some(&p), [op(1, 0), op(2, 0), op(3, 0)], |_| registered = true); + let rejected = ws + .add_outpoints(Some(&p), [op(1, 0), op(2, 0), op(3, 0)], |_| registered = true) + .unwrap_err(); assert!(!registered, "an add that overflows quota registers nothing"); assert_eq!(q.current("tenant"), 0, "no units charged on a rejected add"); assert_eq!(ws.len(), 0); + assert_eq!( + rejected, + AddRejected { + reason: AddRejectReason::QuotaExceeded { required: 3, held: 0, quota: 2 }, + items: RejectedItems::Outpoints(vec![op(1, 0), op(2, 0), op(3, 0)]), + }, + "the refusal names the cost, the quota and every refused item", + ); + } + + #[test] + fn refused_add_names_only_its_net_new_items() { + let (p, acct) = tenant(2); + let q = acct.quota(); + let mut ws = WatchSet::default(); + ws.add_outpoints(Some(&p), [op(1, 0)], |_| {}).unwrap(); + + // op(1) is a re-assert (held, free); op(2) and op(3) need 2 units but only + // 1 is free, so they are refused and op(1) stays watched. + let rejected = ws + .add_outpoints(Some(&p), [op(1, 0), op(2, 0), op(3, 0)], |_| {}) + .unwrap_err(); + assert_eq!(rejected.reason, AddRejectReason::QuotaExceeded { required: 2, held: 1, quota: 2 }); + assert_eq!( + rejected.items, + RejectedItems::Outpoints(vec![op(2, 0), op(3, 0)]), + "a re-asserted item is still watched, so it is not named", + ); + assert_eq!(ws.len(), 1); + assert_eq!(q.current("tenant"), 1); + } + + #[test] + fn add_without_stream_watch_is_refused_as_permission_denied() { + let acct: Arc = Arc::new(LocalAccounting::new()); + let p = Principal::token( + Arc::from("reader"), + CapabilitySet::EMPTY.with(Capability::StreamSubscribe), + Some(10), + None, + acct, + ); + let mut ws = WatchSet::default(); + let rejected = ws.add_scripts(Some(&p), [sh(1)], "scripts", |_| {}, |_| {}).unwrap_err(); + assert_eq!(rejected.reason, AddRejectReason::PermissionDenied); + assert_eq!(rejected.items, RejectedItems::Scripts(vec![sh(1)])); + assert_eq!(ws.len(), 0); + } + + #[test] + fn entry_cap_refuses_growth_but_not_reasserts() { + let mut ws = WatchSet::with_entry_cap(2); + ws.add_outpoints(None, [op(1, 0), op(2, 0)], |_| {}).unwrap(); + // At the cap, a message that only re-asserts held items grows nothing. + ws.add_outpoints(None, [op(1, 0)], |_| {}).unwrap(); + let mut registered = false; + let rejected = ws + .add_scripts(None, [sh(9)], "scripts", |_| registered = true, |_| {}) + .unwrap_err(); + assert!(!registered); + assert_eq!( + rejected, + AddRejected { + reason: AddRejectReason::CapExceeded { requested: 3, limit: 2 }, + items: RejectedItems::Scripts(vec![sh(9)]), + }, + "the cap spans every kind of watch", + ); + assert_eq!(ws.len(), 2); } fn txid(b: u8) -> Txid { @@ -1457,7 +1705,7 @@ mod tests { let mut ws = WatchSet::default(); ws.add_transactions(Some(&p), [txid(1), txid(2)], |items| { assert_eq!(items.len(), 2) - }); + }).unwrap(); assert_eq!(q.current("tenant"), 2, "two txids charge 2 units"); ws.remove_transactions([txid(1)], |items| assert_eq!(items.len(), 1)); assert_eq!(q.current("tenant"), 1, "per-remove release frees one unit"); @@ -1488,7 +1736,7 @@ mod tests { // Two depths on the SAME txid are two distinct items → two units. ws.add_tx_depths(Some(&p), [(txid(1), 1), (txid(1), 3)], |items| { assert_eq!(items.len(), 2) - }); + }).unwrap(); assert_eq!(q.current("tenant"), 2, "(X,1) and (X,3) charge 2 units"); assert_eq!(ws.len(), 2); @@ -1496,7 +1744,7 @@ mod tests { let mut reg = Vec::new(); ws.add_tx_depths(Some(&p), [(txid(1), 1), (txid(1), 6)], |items| { reg = items.to_vec() - }); + }).unwrap(); assert_eq!(reg, vec![(txid(1), 6)], "only the net-new pair registers"); assert_eq!(q.current("tenant"), 3); @@ -1511,7 +1759,7 @@ mod tests { let (p, acct) = tenant(10); let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_outpoints(Some(&p), [op(1, 0)], |_| {}); + ws.add_outpoints(Some(&p), [op(1, 0)], |_| {}).unwrap(); let mut called = false; ws.remove_outpoints([op(9, 9)], |_| called = true); @@ -1524,7 +1772,7 @@ mod tests { let (p, acct) = tenant(10); let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_outpoints(Some(&p), [op(1, 0), op(2, 0)], |_| {}); + ws.add_outpoints(Some(&p), [op(1, 0), op(2, 0)], |_| {}).unwrap(); assert_eq!(q.current("tenant"), 2); drop(ws); assert_eq!(q.current("tenant"), 0, "full teardown releases all leases"); @@ -1537,7 +1785,7 @@ mod tests { // No principal → no quota, items still tracked for dedup/removal. ws.add_outpoints(None, [op(1, 0), op(1, 0), op(2, 0)], |items| { registered = items.len(); - }); + }).unwrap(); assert_eq!(registered, 2, "intra-message dedup still applies"); assert_eq!(ws.len(), 2); } @@ -1559,7 +1807,7 @@ mod tests { let mut ws = WatchSet::default(); let mut reg1 = 0; - ws.add_outpoints(Some(&p), [op(1, 0)], |items| reg1 = items.len()); + ws.add_outpoints(Some(&p), [op(1, 0)], |items| reg1 = items.len()).unwrap(); assert_eq!(reg1, 1, "first add is within the burst"); assert_eq!(q.current("tenant"), 1); @@ -1567,7 +1815,12 @@ mod tests { // registered or charged, and the existing watch-set is intact (no // teardown). let mut reg2 = 0; - ws.add_outpoints(Some(&p), [op(2, 0)], |items| reg2 = items.len()); + let rejected = ws.add_outpoints(Some(&p), [op(2, 0)], |items| reg2 = items.len()).unwrap_err(); + assert!( + matches!(rejected.reason, AddRejectReason::RateLimited { retry_after_secs } if retry_after_secs >= 1), + "a throttled add says when to retry: {rejected:?}", + ); + assert_eq!(rejected.items, RejectedItems::Outpoints(vec![op(2, 0)])); assert_eq!(reg2, 0, "rate-limited add registers nothing"); assert_eq!(q.current("tenant"), 1, "rate-limited add charges no quota"); assert_eq!(ws.len(), 1, "earlier watch remains after a shed add"); @@ -1608,7 +1861,7 @@ mod tests { let c32 = parse_prefix(&[0x11, 0x22, 0x33, 0x44], 32, 8, 32).unwrap(); // 1 unit let mut reg = 0; - ws.add_prefixes(Some(&p), [c24, c32], |keys| reg = keys.len()); + ws.add_prefixes(Some(&p), [c24, c32], |keys| reg = keys.len()).unwrap(); assert_eq!(reg, 2); assert_eq!(q.current("tenant"), (1 << 8) + 1, "coarseness-priced units"); assert_eq!(ws.len(), 2, "two buckets = two items regardless of unit cost"); @@ -1625,11 +1878,11 @@ mod tests { let q = acct.quota(); let mut ws = WatchSet::default(); let a = parse_prefix(&[0xaa, 0xbb], 16, 8, 32).unwrap(); - ws.add_prefixes(Some(&p), [a], |_| {}); + ws.add_prefixes(Some(&p), [a], |_| {}).unwrap(); let charged = q.current("tenant"); let mut called = false; - ws.add_prefixes(Some(&p), [a], |_| called = true); + ws.add_prefixes(Some(&p), [a], |_| called = true).unwrap(); assert!(!called, "re-asserted bucket registers nothing"); assert_eq!(q.current("tenant"), charged, "dedup: the bucket is not double-charged"); } @@ -1642,7 +1895,15 @@ mod tests { // A k=24 prefix costs 1<<8 = 256 units > quota 10 → whole add rejected. let c = parse_prefix(&[0xaa, 0xbb, 0xcc], 24, 8, 32).unwrap(); let mut registered = false; - ws.add_prefixes(Some(&p), [c], |_| registered = true); + let rejected = ws.add_prefixes(Some(&p), [c], |_| registered = true).unwrap_err(); + assert_eq!( + rejected, + AddRejected { + reason: AddRejectReason::QuotaExceeded { required: 1 << 8, held: 0, quota: 10 }, + items: RejectedItems::Prefixes(vec![c.0]), + }, + "a prefix refusal states its coarseness price", + ); assert!(!registered, "a prefix add that overflows quota registers nothing"); assert_eq!(q.current("tenant"), 0); assert_eq!(ws.len(), 0); @@ -1669,10 +1930,10 @@ mod tests { ); let mut ws = WatchSet::default(); - ws.add_outpoints(Some(&p), [op(1, 0)], |_| {}); - ws.add_outpoints(Some(&p), [op(1, 0)], |_| {}); // duplicate → no-op, free + ws.add_outpoints(Some(&p), [op(1, 0)], |_| {}).unwrap(); + ws.add_outpoints(Some(&p), [op(1, 0)], |_| {}).unwrap(); // duplicate → no-op, free let mut reg3 = 0; - ws.add_outpoints(Some(&p), [op(2, 0)], |items| reg3 = items.len()); + ws.add_outpoints(Some(&p), [op(2, 0)], |items| reg3 = items.len()).unwrap(); assert_eq!(reg3, 1, "a no-op duplicate must not have spent the rate budget"); assert_eq!(ws.len(), 2, "both distinct watches registered"); @@ -1693,7 +1954,7 @@ mod tests { win(&[sh(1), sh(2), sh(3)]), |s| registered = s.to_vec(), |_| {}, - ); + ).unwrap(); assert_eq!(registered, vec![sh(1), sh(2), sh(3)], "every derived script registers"); assert_eq!(q.current("tenant"), 3, "one unit per derived script"); assert_eq!(ws.len(), 3); @@ -1712,7 +1973,7 @@ mod tests { let mut ws = WatchSet::default(); // Directly watch sh(2), then a descriptor whose window also contains it. - ws.add_scripts(Some(&p), [sh(2)], "scripts", |_| {}, |_| {}); + ws.add_scripts(Some(&p), [sh(2)], "scripts", |_| {}, |_| {}).unwrap(); assert_eq!(q.current("tenant"), 1); let mut registered = Vec::new(); ws.add_descriptor( @@ -1721,7 +1982,7 @@ mod tests { win(&[sh(1), sh(2), sh(3)]), |s| registered = s.to_vec(), |_| {}, - ); + ).unwrap(); // sh(2) was already watched → only sh(1), sh(3) are net-new. assert_eq!(registered, vec![sh(1), sh(3)], "the shared script is not re-charged"); assert_eq!(q.current("tenant"), 3, "sh1 + sh2(direct) + sh3"); @@ -1747,8 +2008,8 @@ mod tests { let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "D1".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}); - ws.add_descriptor(Some(&p), "D2".into(), win(&[sh(2), sh(3)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "D1".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}).unwrap(); + ws.add_descriptor(Some(&p), "D2".into(), win(&[sh(2), sh(3)]), |_| {}, |_| {}).unwrap(); // sh(2) shared → charged once; total sh1 + sh2 + sh3. assert_eq!(q.current("tenant"), 3); assert_eq!(ws.len(), 3); @@ -1771,7 +2032,7 @@ mod tests { let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1), sh(2), sh(3)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1), sh(2), sh(3)]), |_| {}, |_| {}).unwrap(); assert_eq!(q.current("tenant"), 3); // Slide the window forward: {1,2,3} → {3,4,5}. 1,2 leave; 4,5 enter; 3 stays. @@ -1783,7 +2044,7 @@ mod tests { win(&[sh(3), sh(4), sh(5)]), |s| registered = s.to_vec(), |s| unregistered = s.to_vec(), - ); + ).unwrap(); assert_eq!(registered, vec![sh(4), sh(5)], "scripts entering the window register"); let mut u = unregistered.clone(); u.sort(); @@ -1798,7 +2059,7 @@ mod tests { let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1)]), |_| {}, |_| {}).unwrap(); assert_eq!(q.current("tenant"), 1); // A direct RemoveScripts does not touch descriptor ownership. @@ -1817,13 +2078,18 @@ mod tests { // A 3-script descriptor does not fit → the whole add is rejected. let mut registered = false; - ws.add_descriptor( + let rejected = ws.add_descriptor( Some(&p), "D".into(), win(&[sh(1), sh(2), sh(3)]), |_| registered = true, |_| {}, ); + assert_eq!( + rejected, + Err((AddRejectReason::QuotaExceeded { required: 3, held: 0, quota: 2 }, false)), + "a new descriptor that does not fit is refused and not kept", + ); assert!(!registered, "an over-quota descriptor registers nothing"); assert_eq!(q.current("tenant"), 0, "no units charged"); assert_eq!(ws.len(), 0); @@ -1831,13 +2097,32 @@ mod tests { ws.remove_descriptor("D", |_| panic!("nothing should release")); } + #[test] + fn refused_descriptor_slide_keeps_the_earlier_window() { + let (p, acct) = tenant(2); + let q = acct.quota(); + let mut ws = WatchSet::default(); + ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1)]), |_| {}, |_| {}).unwrap(); + + // Sliding to two new scripts needs 2 units with only 1 free. + let rejected = ws.add_descriptor(Some(&p), "D".into(), win(&[sh(2), sh(3)]), |_| {}, |_| {}); + assert_eq!( + rejected, + Err((AddRejectReason::QuotaExceeded { required: 2, held: 1, quota: 2 }, true)), + "a refused slide reports that the earlier window is kept", + ); + assert!(ws.holds_descriptor("D")); + assert!(ws.scripts.contains_key(&sh(1)), "the earlier window still watches its script"); + assert_eq!(q.current("tenant"), 1); + } + #[test] fn re_adding_an_identical_descriptor_window_is_idempotent() { let (p, acct) = tenant(10); let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}).unwrap(); // Same descriptor, same window: nothing net-new, nothing released. let mut registered = false; let mut unregistered = false; @@ -1847,7 +2132,7 @@ mod tests { win(&[sh(1), sh(2)]), |_| registered = true, |_| unregistered = true, - ); + ).unwrap(); assert!(!registered && !unregistered, "a no-op re-assert touches nothing"); assert_eq!(q.current("tenant"), 2, "no double-charge"); @@ -1933,7 +2218,7 @@ mod tests { let q = acct.quota(); let mut ws = WatchSet::default(); // sh1 watched as a DIRECT script. - ws.add_scripts(Some(&p), [sh(1)], "s", |_| {}, |_| {}); + ws.add_scripts(Some(&p), [sh(1)], "s", |_| {}, |_| {}).unwrap(); assert_eq!(q.current("tenant"), 1); // Reload covers the same scripthash via a DESCRIPTOR instead — the exact @@ -2135,14 +2420,25 @@ mod tests { // script, so each past the first has an empty net-new set — exactly the // "free descriptor" path (no quota unit, no rate token) the cap bounds. for i in 0..MAX_DESCRIPTORS_PER_CONNECTION { - ws.add_descriptor(None, format!("D{i}"), win(&[sh(1)]), |_| {}, |_| {}); + ws.add_descriptor(None, format!("D{i}"), win(&[sh(1)]), |_| {}, |_| {}).unwrap(); } assert_eq!(ws.descriptors.len(), MAX_DESCRIPTORS_PER_CONNECTION); // One more *distinct* descriptor is rejected outright — the map, which is // invisible to the quota and to `len()`, does not grow past the cap. let mut registered = false; - ws.add_descriptor(None, "overflow".into(), win(&[sh(1)]), |_| registered = true, |_| {}); + let rejected = + ws.add_descriptor(None, "overflow".into(), win(&[sh(1)]), |_| registered = true, |_| {}); + assert_eq!( + rejected, + Err(( + AddRejectReason::CapExceeded { + requested: MAX_DESCRIPTORS_PER_CONNECTION as u64 + 1, + limit: MAX_DESCRIPTORS_PER_CONNECTION as u64, + }, + false, + )), + ); assert!(!registered, "a descriptor rejected by the cap registers nothing"); assert_eq!( ws.descriptors.len(), @@ -2154,7 +2450,7 @@ mod tests { // Re-asserting (sliding) an already-retained descriptor at the cap still // works — the count cap must never block a window slide. let mut slid = Vec::new(); - ws.add_descriptor(None, "D0".into(), win(&[sh(2)]), |s| slid = s.to_vec(), |_| {}); + ws.add_descriptor(None, "D0".into(), win(&[sh(2)]), |s| slid = s.to_vec(), |_| {}).unwrap(); assert_eq!( ws.descriptors.len(), MAX_DESCRIPTORS_PER_CONNECTION, @@ -2183,7 +2479,7 @@ mod tests { fn attribution_reports_descriptor_branch_and_index() { let (p, _acct) = tenant(10); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "D".into(), win(&[sh(10), sh(11), sh(12)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "D".into(), win(&[sh(10), sh(11), sh(12)]), |_| {}, |_| {}).unwrap(); // `win` models a single-branch /0/* window: branch 0, index = position. assert_eq!(attrib(&ws, sh(10)), vec![("D".to_string(), 0, 0)]); assert_eq!(attrib(&ws, sh(11)), vec![("D".to_string(), 0, 1)]); @@ -2197,7 +2493,7 @@ mod tests { let (p, _acct) = tenant(10); let mut ws = WatchSet::default(); let two_branch = vec![(0u32, 7u32, sh(1)), (1u32, 7u32, sh(2))]; - ws.add_descriptor(Some(&p), "M".into(), two_branch, |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "M".into(), two_branch, |_| {}, |_| {}).unwrap(); assert_eq!(attrib(&ws, sh(1)), vec![("M".to_string(), 0, 7)], "external branch"); assert_eq!(attrib(&ws, sh(2)), vec![("M".to_string(), 1, 7)], "change branch, same index"); } @@ -2206,7 +2502,7 @@ mod tests { fn direct_scripts_have_no_attribution() { let (p, _acct) = tenant(10); let mut ws = WatchSet::default(); - ws.add_scripts(Some(&p), [sh(1)], "scripts", |_| {}, |_| {}); + ws.add_scripts(Some(&p), [sh(1)], "scripts", |_| {}, |_| {}).unwrap(); assert!(ws.descriptor_attribution(&sh(1)).is_empty()); } @@ -2214,8 +2510,8 @@ mod tests { fn overlapping_descriptors_attribute_a_shared_script_to_both() { let (p, _acct) = tenant(10); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "A".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}); - ws.add_descriptor(Some(&p), "B".into(), win(&[sh(9), sh(2)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "A".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}).unwrap(); + ws.add_descriptor(Some(&p), "B".into(), win(&[sh(9), sh(2)]), |_| {}, |_| {}).unwrap(); // sh(2) is offset 1 in A and offset 1 in B. let mut got = attrib(&ws, sh(2)); got.sort(); @@ -2226,9 +2522,9 @@ mod tests { fn sliding_a_window_updates_offsets_and_drops_departed_scripts() { let (p, _acct) = tenant(10); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1), sh(2), sh(3)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "D".into(), win(&[sh(1), sh(2), sh(3)]), |_| {}, |_| {}).unwrap(); // Slide: {1,2,3} → {3,4,5}. sh(3) moves from offset 2 to offset 0. - ws.add_descriptor(Some(&p), "D".into(), win(&[sh(3), sh(4), sh(5)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "D".into(), win(&[sh(3), sh(4), sh(5)]), |_| {}, |_| {}).unwrap(); assert_eq!(attrib(&ws, sh(3)), vec![("D".to_string(), 0, 0)], "surviving script re-offset"); assert_eq!(attrib(&ws, sh(4)), vec![("D".to_string(), 0, 1)]); assert!(ws.descriptor_attribution(&sh(1)).is_empty(), "departed script loses attribution"); @@ -2238,8 +2534,8 @@ mod tests { fn removing_a_descriptor_clears_attribution_but_keeps_shared() { let (p, _acct) = tenant(10); let mut ws = WatchSet::default(); - ws.add_descriptor(Some(&p), "A".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}); - ws.add_descriptor(Some(&p), "B".into(), win(&[sh(2)]), |_| {}, |_| {}); + ws.add_descriptor(Some(&p), "A".into(), win(&[sh(1), sh(2)]), |_| {}, |_| {}).unwrap(); + ws.add_descriptor(Some(&p), "B".into(), win(&[sh(2)]), |_| {}, |_| {}).unwrap(); ws.remove_descriptor("A", |_| {}); assert!(ws.descriptor_attribution(&sh(1)).is_empty(), "A's exclusive script cleared"); assert_eq!(attrib(&ws, sh(2)), vec![("B".to_string(), 0, 0)], "B still attributes the shared script"); @@ -2265,13 +2561,13 @@ mod tests { let mut registered = 0; ws.add_silent_payments(Some(&p), vec![sp_target(1), sp_target(2)], |ts| { registered = ts.len(); - }); + }).unwrap(); assert_eq!(registered, 2); assert_eq!(q.current("tenant"), 2, "two SP targets charge 2 units"); assert_eq!(ws.len(), 2); // Re-asserting a held identity is not double-charged. - ws.add_silent_payments(Some(&p), vec![sp_target(1)], |_| {}); + ws.add_silent_payments(Some(&p), vec![sp_target(1)], |_| {}).unwrap(); assert_eq!(q.current("tenant"), 2, "re-asserted identity is not recharged"); let mut unregistered = 0; @@ -2291,7 +2587,7 @@ mod tests { let (p, acct) = tenant(10); let q = acct.quota(); let mut ws = WatchSet::default(); - ws.add_silent_payments(Some(&p), vec![sp_target(1)], |_| {}); + ws.add_silent_payments(Some(&p), vec![sp_target(1)], |_| {}).unwrap(); assert_eq!(ws.len(), 1); assert_eq!(q.current("tenant"), 1); @@ -2299,7 +2595,7 @@ mod tests { let mut forwarded: Vec<[u8; 33]> = Vec::new(); ws.add_silent_payments(Some(&p), vec![sp_target(1)], |ts| { forwarded = ts.iter().map(|t| t.scan_pubkey()).collect(); - }); + }).unwrap(); assert_eq!( forwarded, vec![id1], @@ -2314,13 +2610,24 @@ mod tests { // No quota bound (loopback): only the SP cap gates the add. let mut ws = WatchSet::default(); let full: Vec<_> = (1..=MAX_SP_TARGETS_PER_CONNECTION as u8).map(sp_target).collect(); - ws.add_silent_payments(None, full, |_| {}); + ws.add_silent_payments(None, full, |_| {}).unwrap(); assert_eq!(ws.len(), MAX_SP_TARGETS_PER_CONNECTION); - // One more target over the cap is shed whole; the set is unchanged. - let mut registered = true; - ws.add_silent_payments(None, vec![sp_target(200)], |_| registered = true); - // (register is a no-op closure marker; assert by size instead.) - let _ = registered; + // One more target over the cap is refused whole; the set is unchanged. + let mut registered = false; + let rejected = + ws.add_silent_payments(None, vec![sp_target(200)], |_| registered = true).unwrap_err(); + assert!(!registered, "an over-cap target reaches no matcher"); + assert_eq!( + rejected, + AddRejected { + reason: AddRejectReason::CapExceeded { + requested: MAX_SP_TARGETS_PER_CONNECTION as u64 + 1, + limit: MAX_SP_TARGETS_PER_CONNECTION as u64, + }, + items: RejectedItems::SilentPayments(vec![sp_target(200).scan_pubkey()]), + }, + "an SP refusal names the target by identity", + ); assert_eq!(ws.len(), MAX_SP_TARGETS_PER_CONNECTION, "over-cap add is shed"); } diff --git a/events/src/ws.rs b/events/src/ws.rs index c071ef8f6..b08d2e618 100644 --- a/events/src/ws.rs +++ b/events/src/ws.rs @@ -595,7 +595,7 @@ async fn ws_conn( // inbound task; quota is released per-remove, and the remainder when // `ws_conn` returns (alongside the `WatchHandle` deregister). let watch_set: Arc> = - Arc::new(std::sync::Mutex::new(WatchSet::default())); + Arc::new(std::sync::Mutex::new(WatchSet::with_entry_cap(state.max_subscriptions))); // Liveness: every frame from the client (including Pong) refreshes this; // the outbound loop reaps the connection if it goes silent past the idle // timeout, so a dead/half-open peer cannot pin a connection slot. @@ -604,13 +604,12 @@ async fn ws_conn( // Inbound control reader: applies watch-set + category changes against the // shared connection-scoped watch-set. - let max_subscriptions = state.max_subscriptions; let prefix_bounds = (state.prefix_min_bits, state.prefix_max_bits); - // Bridge the deterministic result of an atomic SetWatchSet replace (applied - // by the inbound reader under the watch-set lock) to the outbound loop, which - // emits the in-band `watch_set_result`. - let (ws_result_tx, mut ws_result_rx) = - tokio::sync::mpsc::channel::(32); + // Bridge the deterministic result of an atomic SetWatchSet replace, or the + // refusal of an incremental add (both applied by the inbound reader under the + // watch-set lock), to the outbound loop, which emits the in-band + // `watch_set_result` / `watch_add_rejected`. + let (ws_result_tx, mut ws_result_rx) = tokio::sync::mpsc::channel::(32); let inbound = { let handle = handle.clone(); let category_mask = category_mask.clone(); @@ -632,7 +631,6 @@ async fn ws_conn( &category_mask, &include_raw_tx, &mut guard, - max_subscriptions, prefix_bounds, ) }; @@ -721,9 +719,14 @@ async fn ws_conn( } Err(broadcast::error::RecvError::Closed) => break, }, - // Deterministic result of an atomic SetWatchSet replace. - Some(outcome) = ws_result_rx.recv() => { - let text = watch_set_result_json(&outcome).to_string(); + // Deterministic result of an atomic SetWatchSet replace, or an + // incremental add the watch-set refused. + Some(reply) = ws_result_rx.recv() => { + let text = match &reply { + WsReply::WatchSet(outcome) => watch_set_result_json(outcome), + WsReply::AddRejected(rejected) => watch_add_rejected_json(rejected), + } + .to_string(); if sender.send(Message::Text(text.into())).await.is_err() { break; } @@ -971,6 +974,15 @@ fn parse_ws_scripthash(s: &str) -> Option<[u8; 32]> { Some(sh) } +/// What the inbound reader hands the outbound loop after applying a control +/// message: the outcome of a `set_watch_set`, or the refusal of an incremental +/// add. +#[derive(Debug)] +enum WsReply { + WatchSet(crate::watchset::ReplaceOutcome), + AddRejected(crate::watchset::AddRejected), +} + #[allow(clippy::too_many_arguments)] fn apply_ws_control( text: &str, @@ -979,9 +991,9 @@ fn apply_ws_control( category_mask: &AtomicU32, include_raw_tx: &AtomicBool, watch_set: &mut WatchSet, - max_subscriptions: usize, prefix_bounds: (u8, u8), -) -> Option { +) -> Option { + use crate::watchset::{AddRejectReason, AddRejected, RejectedItems}; let (prefix_min_bits, prefix_max_bits) = prefix_bounds; let ctrl: WsControl = match serde_json::from_str(text) { Ok(c) => c, @@ -1076,7 +1088,10 @@ fn apply_ws_control( Ok(desired) => { // Bound the replace by the same per-connection entry cap the // incremental adds respect (streamwsmaxsubscriptions). - let outcome = watch_set.replace(principal, desired, max_subscriptions, handle); + // The connection's entry cap (`streamwsmaxsubscriptions`) bounds a + // replace exactly as it bounds the incremental adds. + let cap = watch_set.entry_cap(); + let outcome = watch_set.replace(principal, desired, cap, handle); // The category filter is part of the desired set: apply it only // when the replace was accepted, so a rejection leaves the whole // set (categories included) unchanged as `watch_set_result` @@ -1105,33 +1120,14 @@ fn apply_ws_control( outcome } }; - return Some(outcome); - } - // Per-connection watch-set entry cap (`streamwsmaxsubscriptions`; 0 ⇒ - // unlimited): once the set is at/over the cap, shed any add (the - // connection stays up — no per-message ack). Removes and category changes - // are exempt. A single add may overshoot by one control message's worth of - // items, itself bounded by the inbound frame cap. - let is_add = matches!( - ctrl, - WsControl::AddOutpoints { .. } - | WsControl::AddScripts { .. } - | WsControl::AddDescriptor { .. } - | WsControl::AddTransactions { .. } - | WsControl::AddScriptPrefixes { .. } - | WsControl::AddSilentPayments { .. } - ); - if is_add && max_subscriptions != 0 && watch_set.len() >= max_subscriptions { - warn!( - target: "events::ws", - cap = max_subscriptions, - "streamws watch-set at per-connection cap; shedding add", - ); - return None; + return Some(WsReply::WatchSet(outcome)); } - match ctrl { + // The per-connection entry cap (`streamwsmaxsubscriptions`) is the + // watch-set's own (`WatchSet::with_entry_cap`): an add with net-new items + // to a set already at the cap is refused and reported like any other. + let rejected: Option = match ctrl { // Handled and returned above; kept for match exhaustiveness. - WsControl::SetWatchSet { .. } => {} + WsControl::SetWatchSet { .. } => None, WsControl::SetCategories { categories } => { let mask = if categories == 0 { node::events::ALL_CATEGORIES_DEFAULT @@ -1149,22 +1145,20 @@ fn apply_ws_control( ), Ordering::Relaxed, ); + None } WsControl::SetWatchOptions { include_raw_tx: want } => { // Store the encoder-side flag AND toggle the registry gate counter so // the matcher serializes only when opted in; both must agree. include_raw_tx.store(want, Ordering::Relaxed); handle.set_raw_tx(want); + None } - WsControl::AddOutpoints { outpoints } => { - watch_set.add_outpoints( - principal, - outpoints.iter().filter_map(parse_ws_outpoint), - |ops| { - handle.add_outpoints(ops); - }, - ); - } + WsControl::AddOutpoints { outpoints } => watch_set + .add_outpoints(principal, outpoints.iter().filter_map(parse_ws_outpoint), |ops| { + handle.add_outpoints(ops); + }) + .err(), WsControl::RemoveOutpoints { outpoints } => { watch_set.remove_outpoints( outpoints.iter().filter_map(parse_ws_outpoint), @@ -1172,6 +1166,7 @@ fn apply_ws_control( handle.remove_outpoints(ops); }, ); + None } WsControl::AddScripts { scripthashes, @@ -1187,6 +1182,12 @@ fn apply_ws_control( scripthashes = scripthashes.len(), "streamws AddScripts min_values length mismatch; ignoring add", ); + Some(AddRejected { + reason: AddRejectReason::Malformed, + items: RejectedItems::Scripts( + scripthashes.iter().filter_map(|s| parse_ws_scripthash(s)).collect(), + ), + }) } else { let floors: std::collections::HashMap<[u8; 32], u64> = scripthashes .iter() @@ -1207,13 +1208,15 @@ fn apply_ws_control( .collect(); handle.add_scripthashes_with_floors(&items); }; - watch_set.add_scripts( - principal, - scripthashes.iter().filter_map(|s| parse_ws_scripthash(s)), - "scripts", - apply_floors, - apply_floors, - ); + watch_set + .add_scripts( + principal, + scripthashes.iter().filter_map(|s| parse_ws_scripthash(s)), + "scripts", + apply_floors, + apply_floors, + ) + .err() } } WsControl::RemoveScripts { scripthashes } => { @@ -1223,6 +1226,7 @@ fn apply_ws_control( handle.remove_scripthashes(shs); }, ); + None } WsControl::AddTransactions { txids, @@ -1232,19 +1236,28 @@ fn apply_ws_control( let parsed: Vec = txids.iter().filter_map(|s| parse_ws_txid(s)).collect(); let depths: Vec = min_depths.iter().copied().filter(|d| *d >= 1).collect(); if depths.is_empty() { - watch_set.add_transactions(principal, parsed, |txids| { - handle.add_txids(txids, auto_close_depth); - }); + watch_set + .add_transactions(principal, parsed, |txids| { + handle.add_txids(txids, auto_close_depth); + }) + .err() } else if let Some(pairs) = bounded_txid_depth_pairs(&parsed, &depths) { - watch_set.add_tx_depths(principal, pairs, |items| { - handle.add_tx_depths(items); - }); + watch_set + .add_tx_depths(principal, pairs, |items| { + handle.add_tx_depths(items); + }) + .err() } else { warn!( target: "events::ws", txids = parsed.len(), depths = depths.len(), "add_transactions txid×depth product exceeds cap; rejecting message", ); + // Too large to echo; the kind still says which add. + Some(AddRejected { + reason: AddRejectReason::Malformed, + items: RejectedItems::DepthAlarms(Vec::new()), + }) } } WsControl::RemoveTransactions { txids, min_depths } => { @@ -1265,42 +1278,60 @@ fn apply_ws_control( "remove_transactions txid×depth product exceeds cap; rejecting message", ); } + None } WsControl::AddDescriptor { descriptor, gap_limit, start, - } => match crate::descriptor::expand_descriptor(&descriptor, start, gap_limit) { - Ok(scripts) => { - watch_set.add_descriptor( - principal, - descriptor.clone(), - scripts, - |shs| { - handle.add_scripthashes(shs); - }, - |shs| { - handle.remove_scripthashes(shs); - }, - ); - } - Err(e) => { - warn!(target: "events::ws", error = %e, "ignoring invalid descriptor"); + } => { + let refused = |reason, kept| AddRejected { + reason, + items: RejectedItems::Descriptor { + descriptor: descriptor.clone(), + gap_limit, + start, + kept, + }, + }; + match crate::descriptor::expand_descriptor(&descriptor, start, gap_limit) { + Ok(scripts) => watch_set + .add_descriptor( + principal, + descriptor.clone(), + scripts, + |shs| { + handle.add_scripthashes(shs); + }, + |shs| { + handle.remove_scripthashes(shs); + }, + ) + .err() + .map(|(reason, kept)| refused(reason, kept)), + Err(e) => { + warn!(target: "events::ws", error = %e, "ignoring invalid descriptor"); + let kept = watch_set.holds_descriptor(&descriptor); + Some(refused(AddRejectReason::Malformed, kept)) + } } - }, + } WsControl::RemoveDescriptor { descriptor } => { watch_set.remove_descriptor(&descriptor, |shs| { handle.remove_scripthashes(shs); }); + None } WsControl::AddScriptPrefixes { prefixes } => { let items: Vec<((u8, u32), u64)> = prefixes .iter() .filter_map(|p| parse_ws_prefix(p, prefix_min_bits, prefix_max_bits)) .collect(); - watch_set.add_prefixes(principal, items, |keys| { - handle.add_prefixes(keys); - }); + watch_set + .add_prefixes(principal, items, |keys| { + handle.add_prefixes(keys); + }) + .err() } WsControl::RemoveScriptPrefixes { prefixes } => { let keys: Vec<(u8, u32)> = prefixes @@ -1310,6 +1341,7 @@ fn apply_ws_control( watch_set.remove_prefixes(keys, |keys| { handle.remove_prefixes(keys); }); + None } WsControl::AddSilentPayments { targets } => { // Validate each target; skip invalid ones (best-effort, like the @@ -1325,10 +1357,14 @@ fn apply_ws_control( "ignoring invalid silent-payment target(s) in add_silent_payments", ); } - if !parsed.is_empty() { - watch_set.add_silent_payments(principal, parsed, |ts| { - handle.add_silent_payments(ts); - }); + if parsed.is_empty() { + None + } else { + watch_set + .add_silent_payments(principal, parsed, |ts| { + handle.add_silent_payments(ts); + }) + .err() } } WsControl::RemoveSilentPayments { scan_pubkeys } => { @@ -1342,9 +1378,87 @@ fn apply_ws_control( handle.remove_silent_payments(ids); }); } + None } - } - None + }; + rejected.map(WsReply::AddRejected) +} + +/// Hand-rolled JSON for an incremental add the watch-set refused — the JSON +/// mirror of the `WatchAddRejected` node event. Items are echoed the way the +/// add names them: scripthashes and scan pubkeys as plain hex, txids in the +/// display (reversed) hex the add parses. +fn watch_add_rejected_json(rejected: &crate::watchset::AddRejected) -> serde_json::Value { + use crate::watchset::{AddRejectReason, RejectedItems}; + let mut body = serde_json::Map::new(); + body.insert("category".into(), json!("watch_add_rejected")); + let (reason, required, held, quota, retry_after_secs) = match rejected.reason { + AddRejectReason::QuotaExceeded { required, held, quota } => { + ("quota_exceeded", required, held, quota, 0) + } + AddRejectReason::RateLimited { retry_after_secs } => { + ("rate_limited", 0, 0, 0, retry_after_secs) + } + AddRejectReason::CapExceeded { requested, limit } => ("cap_exceeded", requested, 0, limit, 0), + AddRejectReason::PermissionDenied => ("permission_denied", 0, 0, 0, 0), + AddRejectReason::Malformed => ("malformed", 0, 0, 0, 0), + }; + body.insert("reason".into(), json!(reason)); + body.insert("required".into(), json!(required)); + body.insert("held".into(), json!(held)); + body.insert("quota".into(), json!(quota)); + body.insert("retry_after_secs".into(), json!(retry_after_secs)); + let (kind, field, items) = match &rejected.items { + RejectedItems::Scripts(shs) => { + ("scripts", "scripthashes", json!(shs.iter().map(hex::encode).collect::>())) + } + RejectedItems::Outpoints(ops) => ( + "outpoints", + "outpoints", + json!(ops + .iter() + .map(|op| json!({ "txid": op.txid.to_string(), "vout": op.vout })) + .collect::>()), + ), + RejectedItems::Transactions(txids) => ( + "transactions", + "txids", + json!(txids.iter().map(|t| t.to_string()).collect::>()), + ), + RejectedItems::DepthAlarms(pairs) => ( + "depth_alarms", + "depth_alarms", + json!(pairs + .iter() + .map(|(t, depth)| json!({ "txid": t.to_string(), "depth": depth })) + .collect::>()), + ), + RejectedItems::Descriptor { descriptor, gap_limit, start, kept } => { + body.insert("gap_limit".into(), json!(gap_limit)); + body.insert("start".into(), json!(start)); + body.insert("descriptor_kept".into(), json!(kept)); + ("descriptor", "descriptor", json!(descriptor)) + } + RejectedItems::Prefixes(keys) => ( + "script_prefixes", + "prefixes", + json!(keys + .iter() + .map(|(bits, masked)| json!({ + "prefix": hex::encode(&masked.to_be_bytes()[..usize::from(*bits).div_ceil(8)]), + "bits": bits, + })) + .collect::>()), + ), + RejectedItems::SilentPayments(ids) => ( + "silent_payments", + "scan_pubkeys", + json!(ids.iter().map(hex::encode).collect::>()), + ), + }; + body.insert("kind".into(), json!(kind)); + body.insert(field.into(), items); + json!({ "schema_version": node::events::SCHEMA_VERSION, "cursor": null, "body": body }) } /// Hand-rolled JSON for the deterministic result of an atomic `SetWatchSet` @@ -1703,7 +1817,7 @@ mod tests { let reg = Arc::new(WatchRegistry::new()); let (handle, _rx) = reg.register(WATCH_CHANNEL_CAPACITY); let mask = AtomicU32::new(u32::MAX); - let mut ws = WatchSet::default(); + let mut ws = WatchSet::with_entry_cap(2); let txid = "00".repeat(32); let add = |vout: u32| { format!( @@ -1711,23 +1825,43 @@ mod tests { ) }; // cap = 2, no principal (loopback/unlimited quota). - apply_ws_control(&add(0), &handle, None, &mask, &AtomicBool::new(false), &mut ws, 2, (8, 32)); - apply_ws_control(&add(1), &handle, None, &mask, &AtomicBool::new(false), &mut ws, 2, (8, 32)); + apply_ws_control(&add(0), &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); + apply_ws_control(&add(1), &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_eq!(ws.len(), 2, "two distinct outpoints registered"); - // At the cap → the next add is shed (connection stays up). - apply_ws_control(&add(2), &handle, None, &mask, &AtomicBool::new(false), &mut ws, 2, (8, 32)); + // At the cap → the next add is refused and reported (connection stays up). + let reply = + apply_ws_control(&add(2), &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); + let Some(WsReply::AddRejected(rejected)) = reply else { + panic!("an add at the cap must be reported, got {reply:?}"); + }; + assert_eq!(rejected.reason, crate::watchset::AddRejectReason::CapExceeded { requested: 3, limit: 2 }); + let json = watch_add_rejected_json(&rejected); + assert_eq!(json["body"]["category"], "watch_add_rejected"); + assert_eq!(json["body"]["kind"], "outpoints"); + assert_eq!(json["body"]["reason"], "cap_exceeded"); + assert_eq!((json["body"]["required"].as_u64(), json["body"]["quota"].as_u64()), (Some(3), Some(2))); + assert_eq!( + json["body"]["outpoints"], + serde_json::json!([{ "txid": txid, "vout": 2 }]), + "the refused outpoint is echoed in the hex the add used", + ); assert_eq!(ws.len(), 2, "add at the per-connection cap is shed"); // A remove frees a slot; a subsequent add then succeeds. let rm = format!( r#"{{"type":"remove_outpoints","outpoints":[{{"txid":"{txid}","vout":0}}]}}"# ); - apply_ws_control(&rm, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 2, (8, 32)); + apply_ws_control(&rm, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_eq!(ws.len(), 1); - apply_ws_control(&add(2), &handle, None, &mask, &AtomicBool::new(false), &mut ws, 2, (8, 32)); + let reply = + apply_ws_control(&add(2), &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); + assert!(reply.is_none(), "an add that lands sends nothing back"); assert_eq!(ws.len(), 2, "add succeeds again after a remove frees a slot"); // cap = 0 ⇒ unlimited: adds are never shed. - apply_ws_control(&add(3), &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); - assert_eq!(ws.len(), 3, "cap 0 disables the per-connection limit"); + let mut uncapped = WatchSet::with_entry_cap(0); + for vout in 0..3 { + apply_ws_control(&add(vout), &handle, None, &mask, &AtomicBool::new(false), &mut uncapped, (8, 32)); + } + assert_eq!(uncapped.len(), 3, "cap 0 disables the per-connection limit"); } /// The handshake gate. `status` carries host telemetry that `rpc:read` @@ -1779,7 +1913,7 @@ mod tests { r#"{{"type":"set_categories","categories":{}}}"#, node::events::CATEGORY_STATUS | node::events::CATEGORY_CHAIN, ); - apply_ws_control(&ctrl, &handle, Some(&p), &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + apply_ws_control(&ctrl, &handle, Some(&p), &mask, &AtomicBool::new(false), &mut ws, (8, 32)); let got = mask.load(Ordering::Relaxed); assert_eq!(got & node::events::CATEGORY_STATUS, 0, "status must not be added"); assert_ne!(got & node::events::CATEGORY_CHAIN, 0, "the rest of the update applies"); @@ -1791,7 +1925,7 @@ mod tests { .with(Capability::StreamSubscribe) .with(Capability::RpcRead), ); - apply_ws_control(&ctrl, &handle, Some(&p2), &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + apply_ws_control(&ctrl, &handle, Some(&p2), &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_ne!( mask.load(Ordering::Relaxed) & node::events::CATEGORY_STATUS, 0, @@ -1823,9 +1957,9 @@ mod tests { "11".repeat(32), "22".repeat(32), ); - let outcome = apply_ws_control(&ctrl, &handle, Some(&p), &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + let outcome = apply_ws_control(&ctrl, &handle, Some(&p), &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert!( - matches!(outcome, Some(crate::watchset::ReplaceOutcome::Rejected { .. })), + matches!(outcome, Some(WsReply::WatchSet(crate::watchset::ReplaceOutcome::Rejected { .. }))), "over-quota target must be rejected", ); assert_eq!(mask.load(Ordering::Relaxed), 0xF, "rejected replace must not touch categories"); @@ -1836,8 +1970,8 @@ mod tests { r#"{{"type":"set_watch_set","categories":2,"scripthashes":["{}"]}}"#, "11".repeat(32), ); - let outcome = apply_ws_control(&ok, &handle, Some(&p), &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); - assert!(matches!(outcome, Some(crate::watchset::ReplaceOutcome::Accepted { .. }))); + let outcome = apply_ws_control(&ok, &handle, Some(&p), &mask, &AtomicBool::new(false), &mut ws, (8, 32)); + assert!(matches!(outcome, Some(WsReply::WatchSet(crate::watchset::ReplaceOutcome::Accepted { .. })))); assert_eq!(mask.load(Ordering::Relaxed), 2, "accepted replace applies its category filter"); } @@ -1857,8 +1991,8 @@ mod tests { "aa".repeat(32), "bb".repeat(32), ); - let outcome = apply_ws_control(&seed, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); - assert!(matches!(outcome, Some(crate::watchset::ReplaceOutcome::Accepted { .. }))); + let outcome = apply_ws_control(&seed, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); + assert!(matches!(outcome, Some(WsReply::WatchSet(crate::watchset::ReplaceOutcome::Accepted { .. })))); assert_eq!(ws.len(), 2); assert_eq!(mask.load(Ordering::Relaxed), 2); @@ -1868,9 +2002,9 @@ mod tests { r#"{{"type":"set_watch_set","categories":8,"scripthashes":["{}","zz"]}}"#, "cc".repeat(32), ); - let outcome = apply_ws_control(&bad, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + let outcome = apply_ws_control(&bad, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert!( - matches!(outcome, Some(crate::watchset::ReplaceOutcome::Malformed)), + matches!(outcome, Some(WsReply::WatchSet(crate::watchset::ReplaceOutcome::Malformed))), "a malformed element refuses the whole snapshot", ); assert_eq!(ws.len(), 2, "the live set is untouched by a rejected replace"); @@ -1886,7 +2020,7 @@ mod tests { let reg = Arc::new(WatchRegistry::new()); let (handle, _rx) = reg.register(WATCH_CHANNEL_CAPACITY); let mask = AtomicU32::new(0); - let mut ws = WatchSet::default(); + let mut ws = WatchSet::with_entry_cap(2); // cap = 2. A 3-scripthash replace is rejected whole; nothing registers. let over = format!( @@ -1895,9 +2029,9 @@ mod tests { "22".repeat(32), "33".repeat(32), ); - let outcome = apply_ws_control(&over, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 2, (8, 32)); + let outcome = apply_ws_control(&over, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert!( - matches!(outcome, Some(crate::watchset::ReplaceOutcome::CapExceeded { limit: 2, requested: 3 })), + matches!(outcome, Some(WsReply::WatchSet(crate::watchset::ReplaceOutcome::CapExceeded { limit: 2, requested: 3 }))), "over-cap SetWatchSet must be rejected, got {outcome:?}", ); assert_eq!(ws.len(), 0, "a cap-rejected replace installs nothing"); @@ -1912,11 +2046,75 @@ mod tests { "11".repeat(32), "22".repeat(32), ); - let outcome = apply_ws_control(&ok, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 2, (8, 32)); - assert!(matches!(outcome, Some(crate::watchset::ReplaceOutcome::Accepted { .. }))); + let outcome = apply_ws_control(&ok, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); + assert!(matches!(outcome, Some(WsReply::WatchSet(crate::watchset::ReplaceOutcome::Accepted { .. })))); assert_eq!(ws.len(), 2); } + #[test] + fn watch_add_rejected_json_echoes_items_the_way_the_add_named_them() { + use crate::watchset::{AddRejectReason, AddRejected, RejectedItems}; + use std::str::FromStr; + let txid_hex = format!("{}{}", "ab".repeat(31), "01"); + let txid = bitcoin::Txid::from_str(&txid_hex).unwrap(); + + let json = watch_add_rejected_json(&AddRejected { + reason: AddRejectReason::RateLimited { retry_after_secs: 4 }, + items: RejectedItems::DepthAlarms(vec![(txid, 6)]), + }); + assert_eq!(json["body"]["kind"], "depth_alarms"); + assert_eq!(json["body"]["retry_after_secs"], 4); + assert_eq!( + json["body"]["depth_alarms"], + serde_json::json!([{ "txid": txid_hex, "depth": 6 }]), + "a txid comes back in the display hex the add parsed", + ); + + let json = watch_add_rejected_json(&AddRejected { + reason: AddRejectReason::QuotaExceeded { required: 20, held: 90, quota: 100 }, + items: RejectedItems::Descriptor { + descriptor: "wpkh(x)".into(), + gap_limit: 20, + start: 40, + kept: true, + }, + }); + let b = &json["body"]; + assert_eq!((b["kind"].as_str(), b["reason"].as_str()), (Some("descriptor"), Some("quota_exceeded"))); + assert_eq!((b["required"].as_u64(), b["held"].as_u64(), b["quota"].as_u64()), (Some(20), Some(90), Some(100))); + assert_eq!((b["descriptor"].as_str(), b["gap_limit"].as_u64(), b["start"].as_u64()), (Some("wpkh(x)"), Some(20), Some(40))); + assert_eq!(b["descriptor_kept"], true); + + let (key, _) = crate::watchset::parse_prefix(&[0xab, 0xcd], 12, 8, 32).unwrap(); + let json = watch_add_rejected_json(&AddRejected { + reason: AddRejectReason::PermissionDenied, + items: RejectedItems::Prefixes(vec![key]), + }); + assert_eq!(json["body"]["kind"], "script_prefixes"); + assert_eq!(json["body"]["prefixes"], serde_json::json!([{ "prefix": "abc0", "bits": 12 }])); + + let json = watch_add_rejected_json(&AddRejected { + reason: AddRejectReason::CapExceeded { requested: 17, limit: 16 }, + items: RejectedItems::SilentPayments(vec![[0x02; 33]]), + }); + assert_eq!(json["body"]["scan_pubkeys"], serde_json::json!(["02".repeat(33)])); + + // A min_values list that is not parallel to its scripthashes refuses the + // whole add as malformed and echoes the scripthashes that parsed. + let reg = Arc::new(WatchRegistry::new()); + let (handle, _rx) = reg.register(WATCH_CHANNEL_CAPACITY); + let mask = AtomicU32::new(0); + let mut ws = WatchSet::default(); + let bad = format!(r#"{{"type":"add_scripts","scripthashes":["{}"],"min_values":[1,2]}}"#, "11".repeat(32)); + let reply = apply_ws_control(&bad, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); + let Some(WsReply::AddRejected(rejected)) = reply else { + panic!("a malformed add must be reported, got {reply:?}"); + }; + assert_eq!(rejected.reason, AddRejectReason::Malformed); + assert_eq!(rejected.items, RejectedItems::Scripts(vec![[0x11; 32]])); + assert_eq!(ws.len(), 0); + } + #[test] fn script_match_json_amount_and_raw_tx_gate() { use bitcoin::hashes::Hash; @@ -2020,11 +2218,11 @@ mod tests { let ctrl = format!( r#"{{"type":"add_transactions","txids":["{txid}"],"min_depths":[1,3]}}"# ); - apply_ws_control(&ctrl, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + apply_ws_control(&ctrl, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_eq!(ws.len(), 2, "(X,1) and (X,3) are two items"); // Lifecycle add (no depths) is one item. let ctrl = format!(r#"{{"type":"add_transactions","txids":["{txid}"]}}"#); - apply_ws_control(&ctrl, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + apply_ws_control(&ctrl, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_eq!(ws.len(), 3, "lifecycle watch adds one more item"); } @@ -2036,18 +2234,18 @@ mod tests { let mut ws = WatchSet::default(); // A 16-bit prefix (2 bytes hex). No principal ⇒ loopback/unlimited. let ctrl = r#"{"type":"add_script_prefixes","prefixes":[{"prefix":"abcd","bits":16}]}"#; - apply_ws_control(ctrl, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + apply_ws_control(ctrl, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_eq!(ws.len(), 1, "one prefix bucket registered"); assert!(reg.has_prefix_watchers()); // Below-min bits is dropped (filter_map) → nothing registered. let bad = r#"{"type":"add_script_prefixes","prefixes":[{"prefix":"ab","bits":4}]}"#; - apply_ws_control(bad, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + apply_ws_control(bad, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_eq!(ws.len(), 1, "out-of-range bits rejected, set unchanged"); // Remove releases it. let rm = r#"{"type":"remove_script_prefixes","prefixes":[{"prefix":"abcd","bits":16}]}"#; - apply_ws_control(rm, &handle, None, &mask, &AtomicBool::new(false), &mut ws, 0, (8, 32)); + apply_ws_control(rm, &handle, None, &mask, &AtomicBool::new(false), &mut ws, (8, 32)); assert_eq!(ws.len(), 0); assert!(!reg.has_prefix_watchers()); } diff --git a/satd-events-proto/proto/satd/events/v1/events.proto b/satd-events-proto/proto/satd/events/v1/events.proto index 3abc51155..9c7323199 100644 --- a/satd-events-proto/proto/satd/events/v1/events.proto +++ b/satd-events-proto/proto/satd/events/v1/events.proto @@ -848,6 +848,75 @@ message WatchSetRejected { uint64 quota = 3; // the unit ceiling (QUOTA) or entry cap (CAP); 0 for MALFORMED } +// An incremental watch add (AddScripts, AddOutpoints, AddTransactions, +// AddDescriptor, AddScriptPrefixes, AddSilentPayments) that the server did not +// register, delivered in-band on the Watch stream (same pattern as +// WatchSetResult). Emitted once per refused Add* message and never for one that +// registered, so a client that sees none can rely on its adds having landed. +// The add is all-or-nothing over its NET-NEW items: none of the items echoed +// here is watched. Items the message re-asserted (already watched) are not +// echoed and stay watched; their metadata (a script's min_value floor, a +// silent-payment target's labels) still updates, except when the refusal is +// CAP_EXCEEDED or RATE_LIMITED on a silent-payment add, which leaves held +// targets' labels as they were. +// +// `required`/`quota` carry the numbers behind `reason`: +// QUOTA_EXCEEDED — `required` = units the refused items cost, `held` = +// units the principal already holds, `quota` = its unit ceiling. Remove +// watches or ask for a larger quota. +// RATE_LIMITED — `retry_after_secs` = when the per-principal rate limit +// admits another add. The same add may then succeed. +// CAP_EXCEEDED — `required` = the count the add would reach, `quota` = the +// per-connection cap it hit: 16 silent-payment targets, 256 descriptors, or +// the WS entry cap (`streamwsmaxsubscriptions`). +// PERMISSION_DENIED — the token lacks `stream:watch`. +// MALFORMED — the message could not be applied as a whole: a +// `min_values` list not parallel to its scripthashes, an invalid descriptor, +// or a txid × depth product over the per-message cap. Echoes the items +// that parsed, except for an over-cap depth product, which echoes none. +// Individually unparseable items inside an otherwise valid add (a scripthash +// that is not 32 bytes, a prefix outside the allowed bit range, an invalid +// silent-payment key) are skipped without a rejection. +message WatchAddRejected { + enum Kind { + KIND_UNSPECIFIED = 0; + SCRIPTS = 1; // AddScripts → scripthashes + OUTPOINTS = 2; // AddOutpoints → outpoints + TRANSACTIONS = 3; // AddTransactions lifecycle watches → txids + DEPTH_ALARMS = 4; // AddTransactions depth alarms → depth_alarms + DESCRIPTOR = 5; // AddDescriptor → descriptor, gap_limit, start + SCRIPT_PREFIXES = 6; // AddScriptPrefixes → prefixes + SILENT_PAYMENTS = 7; // AddSilentPayments → scan_pubkeys + } + enum Reason { + REASON_UNSPECIFIED = 0; + QUOTA_EXCEEDED = 1; + RATE_LIMITED = 2; + CAP_EXCEEDED = 3; + PERMISSION_DENIED = 4; + MALFORMED = 5; + } + Kind kind = 1; + Reason reason = 2; + uint64 required = 3; + uint64 held = 4; + uint64 quota = 5; + uint32 retry_after_secs = 6; + // The refused items, as the client named them. Only the field for `kind` is set. + repeated bytes scripthashes = 7; // 32 bytes each + repeated Outpoint outpoints = 8; + repeated bytes txids = 9; // lifecycle watches, 32 bytes each + repeated WatchDepthAlarm depth_alarms = 10; + string descriptor = 11; + uint32 gap_limit = 12; // the window the refused AddDescriptor asked for + uint32 start = 13; + // True when an earlier window of `descriptor` stays watched (a refused slide); + // false when the descriptor is not watched at all. + bool descriptor_kept = 14; + repeated ScriptPrefix prefixes = 15; // masked to `bits` + repeated bytes scan_pubkeys = 16; // silent-payment identities b_scan·G (33 bytes); never the secret +} + // Deterministic outcome of a RescanBlocks, delivered in-band ahead of any // matches it admits (same pattern as SetCursorResult). Emitted exactly once per // actionable rescan. On accept, zero or more confirmed watch-match events @@ -935,5 +1004,6 @@ message NodeEvent { SilentPaymentMatched silent_payment_matched = 29; // BIP 352 scan-key watch match (Watch stream, §4) MempoolTweak mempool_tweak = 30; // BIP 352 mempool-time tweak (Subscribe `tweaks` + mempool_tweaks, §3.5) StatusEvent status = 31; // node-health condition (Subscribe `status` category, bit 16) + WatchAddRejected watch_add_rejected = 32; // an incremental watch add the server did not register (Watch stream) } } From 36f7a8d3c207d02385a4d1bb79960c4ba9425a08 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 14:59:35 -0600 Subject: [PATCH 2/9] SDKs surface WatchAddRejected and drop refused items from the mirror Both SDKs decode the new event (Rust Event::WatchAddRejected, Go *WatchAddRejected) with its kind, reason, numbers and items. When it arrives, ResilientWatch drops the refused items from its mirror so a reconnect re-registers only what the node holds; a refused descriptor slide falls back to the window the node kept. The event is still handed to the caller. The parity harness renders the event the same way from both SDKs, and two e2e tests drive it over the real binary: an over-quota gRPC add and a WS add over the per-connection entry cap. Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/go/cmd/paritydump/render.go | 46 +++++ clients/go/enums.go | 99 +++++++++++ clients/go/events.go | 101 +++++++++++ clients/go/events_exhaustive_test.go | 18 ++ clients/go/mirror.go | 62 ++++++- clients/go/resilientwatch.go | 4 + clients/go/resilientwatch_test.go | 87 ++++++++++ clients/go/watchset.go | 16 +- satd-events-client/src/event.rs | 156 +++++++++++++++++ satd-events-client/src/lib.rs | 5 +- satd-events-client/src/resilient_watch.rs | 200 +++++++++++++++++++++- satd-events-client/tests/event_mapping.rs | 53 +++++- satd/tests/e2e/parity.rs | 77 +++++++++ satd/tests/e2e/streaming.rs | 68 ++++++++ 14 files changed, 983 insertions(+), 9 deletions(-) diff --git a/clients/go/cmd/paritydump/render.go b/clients/go/cmd/paritydump/render.go index 1a8d9cb42..c7b2ec6fc 100644 --- a/clients/go/cmd/paritydump/render.go +++ b/clients/go/cmd/paritydump/render.go @@ -192,6 +192,52 @@ func render(ev satdevents.Event) map[string]any { "required": e.Required, "quota": e.Quota, }) + case *satdevents.WatchAddRejected: + scripthashes := make([]any, 0, len(e.Scripthashes)) + for _, b := range e.Scripthashes { + scripthashes = append(scripthashes, hexb(b)) + } + outpoints := make([]any, 0, len(e.Outpoints)) + for _, o := range e.Outpoints { + outpoints = append(outpoints, map[string]any{"txid": hexb(o.Txid), "vout": o.Vout}) + } + txids := make([]any, 0, len(e.Txids)) + for _, b := range e.Txids { + txids = append(txids, hexb(b)) + } + alarms := make([]any, 0, len(e.DepthAlarms)) + for _, a := range e.DepthAlarms { + alarms = append(alarms, map[string]any{"txid": hexb(a.Txid), "depth": a.Depth}) + } + prefixes := make([]any, 0, len(e.Prefixes)) + for _, p := range e.Prefixes { + prefixes = append(prefixes, map[string]any{"prefix": hexb(p.Prefix), "bits": p.Bits}) + } + scanPubkeys := make([]any, 0, len(e.ScanPubkeys)) + for _, b := range e.ScanPubkeys { + scanPubkeys = append(scanPubkeys, hexb(b)) + } + var descriptor any + if d := e.Descriptor; d != nil { + descriptor = map[string]any{ + "descriptor": d.Descriptor, "gap_limit": d.GapLimit, "start": d.Start, "kept": d.Kept, + } + } + return obj("watch_add_rejected", map[string]any{ + "kind": enumName(eventspb.WatchAddRejected_Kind_name, int32(e.Kind)), + "reason": enumName(eventspb.WatchAddRejected_Reason_name, int32(e.Reason)), + "required": e.Required, + "held": e.Held, + "quota": e.Quota, + "retry_after_secs": e.RetryAfterSecs, + "scripthashes": scripthashes, + "outpoints": outpoints, + "txids": txids, + "depth_alarms": alarms, + "descriptor": descriptor, + "prefixes": prefixes, + "scan_pubkeys": scanPubkeys, + }) case *satdevents.RescanAccepted: return obj("rescan_accepted", map[string]any{ "from_height": e.FromHeight, "to_height": e.ToHeight, "clamped": e.Clamped, diff --git a/clients/go/enums.go b/clients/go/enums.go index 24dc7a7ed..0e42760ad 100644 --- a/clients/go/enums.go +++ b/clients/go/enums.go @@ -348,6 +348,105 @@ func (r WatchSetRejectReason) String() string { } } +// WatchAddKind says which kind of add a [WatchAddRejected] refers to. +type WatchAddKind int32 + +// Watch add kinds. +const ( + // WatchAddKindUnspecified is proto3's zero value. + WatchAddKindUnspecified WatchAddKind = 0 + // WatchAddKindScripts - AddScripts. + WatchAddKindScripts WatchAddKind = 1 + // WatchAddKindOutpoints - AddOutpoints. + WatchAddKindOutpoints WatchAddKind = 2 + // WatchAddKindTransactions - AddTxLifecycle. + WatchAddKindTransactions WatchAddKind = 3 + // WatchAddKindDepthAlarms - AddDepthAlarms. + WatchAddKindDepthAlarms WatchAddKind = 4 + // WatchAddKindDescriptor - AddDescriptor. + WatchAddKindDescriptor WatchAddKind = 5 + // WatchAddKindScriptPrefixes - AddScriptPrefixes. + WatchAddKindScriptPrefixes WatchAddKind = 6 + // WatchAddKindSilentPayments - AddSilentPayments. + WatchAddKindSilentPayments WatchAddKind = 7 +) + +// Known reports whether this build recognizes the kind. +func (k WatchAddKind) Known() bool { + return k >= WatchAddKindUnspecified && k <= WatchAddKindSilentPayments +} + +func (k WatchAddKind) String() string { + switch k { + case WatchAddKindUnspecified: + return "unspecified" + case WatchAddKindScripts: + return "scripts" + case WatchAddKindOutpoints: + return "outpoints" + case WatchAddKindTransactions: + return "transactions" + case WatchAddKindDepthAlarms: + return "depth_alarms" + case WatchAddKindDescriptor: + return "descriptor" + case WatchAddKindScriptPrefixes: + return "script_prefixes" + case WatchAddKindSilentPayments: + return "silent_payments" + default: + return "unknown(" + strconv.FormatInt(int64(k), 10) + ")" + } +} + +// WatchAddRejectReason says why the node refused an incremental add (see +// [WatchAddRejected]). +type WatchAddRejectReason int32 + +// Watch add reject reasons. +const ( + // WatchAddRejectUnspecified is proto3's zero value. + WatchAddRejectUnspecified WatchAddRejectReason = 0 + // WatchAddRejectQuotaExceeded - the refused items' unit cost does not fit + // the token's watch quota. Remove watches, or ask for a larger quota. + WatchAddRejectQuotaExceeded WatchAddRejectReason = 1 + // WatchAddRejectRateLimited - the token's per-add rate limit is spent. The + // same add can succeed after RetryAfterSecs. + WatchAddRejectRateLimited WatchAddRejectReason = 2 + // WatchAddRejectCapExceeded - a per-connection cap: 16 silent-payment + // targets, 256 descriptors, or the WebSocket entry cap. + WatchAddRejectCapExceeded WatchAddRejectReason = 3 + // WatchAddRejectPermissionDenied - the token lacks stream:watch. + WatchAddRejectPermissionDenied WatchAddRejectReason = 4 + // WatchAddRejectMalformed - the add could not be applied as a whole. A + // client bug: the same add will fail again. + WatchAddRejectMalformed WatchAddRejectReason = 5 +) + +// Known reports whether this build recognizes the reason. +func (r WatchAddRejectReason) Known() bool { + return r >= WatchAddRejectUnspecified && r <= WatchAddRejectMalformed +} + +func (r WatchAddRejectReason) String() string { + switch r { + case WatchAddRejectUnspecified: + return "unspecified" + case WatchAddRejectQuotaExceeded: + return "quota_exceeded" + case WatchAddRejectRateLimited: + return "rate_limited" + case WatchAddRejectCapExceeded: + return "cap_exceeded" + case WatchAddRejectPermissionDenied: + return "permission_denied" + case WatchAddRejectMalformed: + return "malformed" + default: + return "unknown(" + strconv.FormatInt(int64(r), 10) + ")" + } +} + // RescanRejectReason says why a bounded historical rescan was declined (see // [RescanRejected]). type RescanRejectReason int32 diff --git a/clients/go/events.go b/clients/go/events.go index 5892ab205..f43c5b535 100644 --- a/clients/go/events.go +++ b/clients/go/events.go @@ -594,6 +594,72 @@ type WatchSetRejected struct { Quota uint64 } +// WatchAddRejected reports an incremental watch add (AddScripts, AddOutpoints, +// ...) that the node did NOT register: over the quota, over the per-add rate +// limit, over a per-connection cap, without stream:watch, or malformed. None of +// the items it names is watched. Items the add re-asserted (already watched) +// are not named and stay watched. The node sends nothing for an add that +// registered. +// +// [ResilientWatch] drops the named items from its mirror before handing this +// on, so a reconnect does not re-register them; re-add them yourself if you +// want another try (for example after RetryAfterSecs on a rate limit). Only the +// item field for Kind is set. +type WatchAddRejected struct { + // Kind is which kind of add was refused. + Kind WatchAddKind + // Reason is why it was refused. + Reason WatchAddRejectReason + // Required is, for [WatchAddRejectQuotaExceeded], the units the refused + // items cost; for [WatchAddRejectCapExceeded], the count the add would have + // reached. 0 otherwise. + Required uint64 + // Held is, for WatchAddRejectQuotaExceeded, the units the token already + // holds. 0 otherwise. + Held uint64 + // Quota is, for WatchAddRejectQuotaExceeded, the token's unit quota; for + // WatchAddRejectCapExceeded, the cap. 0 otherwise. + Quota uint64 + // RetryAfterSecs is, for [WatchAddRejectRateLimited], the seconds until the + // rate limit admits another add. 0 otherwise. + RetryAfterSecs uint32 + // Scripthashes are refused script watches (32 bytes each). + Scripthashes [][]byte + // Outpoints are refused outpoint watches. + Outpoints []Outpoint + // Txids are refused lifecycle watches. + Txids [][]byte + // DepthAlarms are refused depth alarms. + DepthAlarms []DepthAlarm + // Descriptor is the refused descriptor and the window it asked for. + Descriptor *RejectedDescriptor + // Prefixes are refused script prefixes, masked to Bits. + Prefixes []ScriptPrefix + // ScanPubkeys are refused silent-payment targets, by identity b_scan*G (33 + // bytes each). + ScanPubkeys [][]byte +} + +// DepthAlarm is one (txid, depth) alarm named by a [WatchAddRejected]. +type DepthAlarm struct { + // Txid is 32 raw bytes in internal byte order. + Txid []byte + // Depth is the requested confirmation depth. + Depth uint32 +} + +// RejectedDescriptor is the descriptor a [WatchAddRejected] names. +type RejectedDescriptor struct { + // Descriptor is the descriptor string the add carried. + Descriptor string + // GapLimit and Start are the window the add asked for. + GapLimit uint32 + Start uint32 + // Kept is true when an earlier window of this descriptor stays watched (a + // refused slide), false when the descriptor is not watched at all. + Kept bool +} + // RescanAccepted reports that a bounded historical rescan was ADMITTED. // Confirmed watch-matches for the scanned range follow this event (in height // order), terminated by a [RescanComplete]. @@ -666,6 +732,7 @@ func (*CursorAccepted) isEvent() {} func (*CursorRejected) isEvent() {} func (*WatchSetReplaced) isEvent() {} func (*WatchSetRejected) isEvent() {} +func (*WatchAddRejected) isEvent() {} func (*RescanAccepted) isEvent() {} func (*RescanRejected) isEvent() {} func (*RescanComplete) isEvent() {} @@ -852,6 +919,8 @@ func decodeEvent(ev *eventspb.NodeEvent) Event { default: return unknownEvent } + case *eventspb.NodeEvent_WatchAddRejected: + return watchAddRejectedFromProto(body.WatchAddRejected) case *eventspb.NodeEvent_RescanResult: switch outcome := body.RescanResult.GetOutcome().(type) { case *eventspb.RescanResult_Accepted: @@ -969,3 +1038,35 @@ func nonEmpty(b []byte) []byte { } return b } + +func watchAddRejectedFromProto(r *eventspb.WatchAddRejected) *WatchAddRejected { + out := &WatchAddRejected{ + Kind: WatchAddKind(r.GetKind()), + Reason: WatchAddRejectReason(r.GetReason()), + Required: r.GetRequired(), + Held: r.GetHeld(), + Quota: r.GetQuota(), + RetryAfterSecs: r.GetRetryAfterSecs(), + Scripthashes: r.GetScripthashes(), + Txids: r.GetTxids(), + ScanPubkeys: r.GetScanPubkeys(), + } + for _, o := range r.GetOutpoints() { + out.Outpoints = append(out.Outpoints, Outpoint{Txid: o.GetTxid(), Vout: o.GetVout()}) + } + for _, d := range r.GetDepthAlarms() { + out.DepthAlarms = append(out.DepthAlarms, DepthAlarm{Txid: d.GetTxid(), Depth: d.GetDepth()}) + } + for _, p := range r.GetPrefixes() { + out.Prefixes = append(out.Prefixes, ScriptPrefix{Prefix: p.GetPrefix(), Bits: p.GetBits()}) + } + if out.Kind == WatchAddKindDescriptor { + out.Descriptor = &RejectedDescriptor{ + Descriptor: r.GetDescriptor_(), + GapLimit: r.GetGapLimit(), + Start: r.GetStart(), + Kept: r.GetDescriptorKept(), + } + } + return out +} diff --git a/clients/go/events_exhaustive_test.go b/clients/go/events_exhaustive_test.go index a68996166..5412bb4bd 100644 --- a/clients/go/events_exhaustive_test.go +++ b/clients/go/events_exhaustive_test.go @@ -57,6 +57,24 @@ var bodyFixtures = map[string]fixture{ ev: &eventspb.NodeEvent{Body: &eventspb.NodeEvent_Status{Status: &eventspb.StatusEvent{}}}, want: &Status{}, }, + "watch_add_rejected": { + ev: &eventspb.NodeEvent{Body: &eventspb.NodeEvent_WatchAddRejected{ + WatchAddRejected: &eventspb.WatchAddRejected{ + Kind: eventspb.WatchAddRejected_DESCRIPTOR, + Reason: eventspb.WatchAddRejected_QUOTA_EXCEEDED, + Descriptor_: "wpkh(x)", GapLimit: 20, Start: 40, DescriptorKept: true, + Required: 20, Held: 90, Quota: 100, + }, + }}, + want: &WatchAddRejected{ + Kind: WatchAddKindDescriptor, + Reason: WatchAddRejectQuotaExceeded, + Required: 20, + Held: 90, + Quota: 100, + Descriptor: &RejectedDescriptor{Descriptor: "wpkh(x)", GapLimit: 20, Start: 40, Kept: true}, + }, + }, "outpoint_spent": { ev: &eventspb.NodeEvent{Body: &eventspb.NodeEvent_OutpointSpent{OutpointSpent: &eventspb.OutpointSpent{}}}, want: &OutpointSpent{}, diff --git a/clients/go/mirror.go b/clients/go/mirror.go index 9d35c6c84..bedd05475 100644 --- a/clients/go/mirror.go +++ b/clients/go/mirror.go @@ -44,6 +44,57 @@ func (w *WatchSet) removeDepthAlarms(txids [][32]byte, depths []uint32) { func (w *WatchSet) removeDescriptor(descriptor string) { delete(w.descriptors, descriptor) + delete(w.descriptorPrev, descriptor) +} + +// forgetRejected drops what the node refused to register, so the mirror holds +// only what the node holds. Items are named exactly as the node echoes them; a +// prefix comes back masked, which removeScriptPrefixes matches. +func (w *WatchSet) forgetRejected(r *WatchAddRejected) { + for _, h := range r.Scripthashes { + if t, ok := txid32(h); ok { + w.removeScripts(t) + } + } + for _, o := range r.Outpoints { + if t, ok := txid32(o.Txid); ok { + w.removeOutpoints(OutpointRef{Txid: t, Vout: o.Vout}) + } + } + for _, t := range r.Txids { + if id, ok := txid32(t); ok { + w.removeTxLifecycle(id) + } + } + for _, a := range r.DepthAlarms { + if t, ok := txid32(a.Txid); ok { + w.removeDepthAlarms([][32]byte{t}, []uint32{a.Depth}) + } + } + w.removeScriptPrefixes(r.Prefixes...) + for _, k := range r.ScanPubkeys { + var id [33]byte + if len(k) == len(id) { + copy(id[:], k) + w.removeSilentPayments(id) + } + } + if d := r.Descriptor; d != nil { + // Only the window the refusal names; a later slide already replaced it. + if cur, ok := w.descriptors[d.Descriptor]; ok && cur == (descriptorWindow{gapLimit: d.GapLimit, start: d.Start}) { + prev, hadPrev := w.descriptorPrev[d.Descriptor] + delete(w.descriptorPrev, d.Descriptor) + switch { + case d.Kept && hadPrev: + w.descriptors[d.Descriptor] = prev + case d.Kept: + // Kept, but the mirror never saw the earlier window (a + // loader-built set): leave the requested one to replay. + default: + delete(w.descriptors, d.Descriptor) + } + } + } } func (w *WatchSet) removeScriptPrefixes(prefixes ...ScriptPrefix) { @@ -97,6 +148,12 @@ func (w *WatchSet) clone() *WatchSet { out.descriptors[k] = v } } + if len(w.descriptorPrev) > 0 { + out.descriptorPrev = make(map[string]descriptorWindow, len(w.descriptorPrev)) + for k, v := range w.descriptorPrev { + out.descriptorPrev[k] = v + } + } if len(w.prefixes) > 0 { out.prefixes = make(map[prefixKey]ScriptPrefix, len(w.prefixes)) for k, v := range w.prefixes { @@ -196,9 +253,8 @@ func (w *WatchSet) controlMessages() ([]*eventspb.SubscribeControl, error) { // AddTransactions is a txids x min_depths CROSS PRODUCT, so every txid // sharing the same depth set travels in one message. Emitting one message // per txid instead spent one server rate-limit token per alarmed txid on - // every reconnect, and the node sheds an over-budget add SILENTLY, with no - // ack - so on a rate-limited node most alarms were simply never - // re-registered. Wallets tend to use the same few depth sets throughout, + // every reconnect, and the node refuses an over-budget add - so on a + // rate-limited node most alarms were never re-registered. Wallets tend to use the same few depth sets throughout, // which collapses hundreds of messages into a handful. { bySet := map[string][][]byte{} diff --git a/clients/go/resilientwatch.go b/clients/go/resilientwatch.go index 76c6ec348..097506fe2 100644 --- a/clients/go/resilientwatch.go +++ b/clients/go/resilientwatch.go @@ -750,6 +750,10 @@ func (w *ResilientWatch) handleEvent(ctx context.Context, ev Event, cur *Cursor, // completed txid. The node reports the REQUESTED threshold as depth (the // alarm's identity), so that is the exact key to drop. switch e := ev.(type) { + case *WatchAddRejected: + // The node refused an add: it does not hold these items, so a reconnect + // must not re-register them. + w.mirror.forgetRejected(e) case *TxidDepthReached: if t, ok := txid32(e.Txid); ok { w.mirror.removeDepthAlarms([][32]byte{t}, []uint32{e.Depth}) diff --git a/clients/go/resilientwatch_test.go b/clients/go/resilientwatch_test.go index 6e89368d9..0a817234e 100644 --- a/clients/go/resilientwatch_test.go +++ b/clients/go/resilientwatch_test.go @@ -1107,3 +1107,90 @@ func waitForAddScripts(t *testing.T, s *scriptedWatch) *eventspb.SubscribeContro t.Fatalf("no AddScripts control on any of %d leg(s)", s.legCount()) return nil } + +// TestRefusedAddsArePrunedFromTheMirror: a WatchAddRejected names items the +// node does not hold. Re-registering them on every reconnect would repeat the +// refusal forever, so the mirror drops them; a refused descriptor slide falls +// back to the window the node still holds. The event still reaches the caller. +func TestRefusedAddsArePrunedFromTheMirror(t *testing.T) { + kept := [32]byte{0x01} + refused := [32]byte{0x02} + client, _ := startScriptedWatch(t, + func(l *watchLeg, s eventspb.NodeEventStream_WatchServer) error { + deadline := time.Now().Add(5 * time.Second) + // The adds land before the stream connects, so the mirror replays + // its net set: AddScripts, AddDescriptor (latest window) and + // AddScriptPrefixes. + for time.Now().Before(deadline) && len(l.controls()) < 3 { + time.Sleep(time.Millisecond) + } + for _, r := range []*eventspb.WatchAddRejected{ + { + Kind: eventspb.WatchAddRejected_SCRIPTS, + Reason: eventspb.WatchAddRejected_QUOTA_EXCEEDED, + Scripthashes: [][]byte{append([]byte(nil), refused[:]...)}, + }, + { + // 12 bits: the node echoes the prefix masked. + Kind: eventspb.WatchAddRejected_SCRIPT_PREFIXES, + Reason: eventspb.WatchAddRejected_CAP_EXCEEDED, + Prefixes: []*eventspb.ScriptPrefix{{Prefix: []byte{0xab, 0xc0}, Bits: 12}}, + }, + { + Kind: eventspb.WatchAddRejected_DESCRIPTOR, + Reason: eventspb.WatchAddRejected_RATE_LIMITED, + Descriptor_: "wpkh(a)", GapLimit: 20, Start: 20, DescriptorKept: true, + }, + } { + if err := s.Send(&eventspb.NodeEvent{Body: &eventspb.NodeEvent_WatchAddRejected{WatchAddRejected: r}}); err != nil { + return err + } + } + time.Sleep(150 * time.Millisecond) + return nil + }, + parkLeg, + ) + w := client.ResilientWatch(context.Background(), ResilientWatchConfig{ + Backoff: Backoff{Initial: time.Millisecond, Max: 5 * time.Millisecond, Multiplier: 2}, + }) + defer func() { _ = w.Close() }() + + ctx := context.Background() + if err := w.AddScripts(ctx, ScriptWatch{Scripthash: kept}, ScriptWatch{Scripthash: refused}); err != nil { + t.Fatal(err) + } + if err := w.AddScriptPrefixes(ctx, ScriptPrefix{Prefix: []byte{0xab, 0xcd}, Bits: 12}); err != nil { + t.Fatal(err) + } + if err := w.AddDescriptor(ctx, "wpkh(a)", 20, 0); err != nil { + t.Fatal(err) + } + if err := w.AddDescriptor(ctx, "wpkh(a)", 20, 20); err != nil { + t.Fatal(err) + } + + for seen := 0; seen < 3; { + ev, err := w.Next(ctx) + if err != nil { + t.Fatal(err) + } + if _, ok := ev.(*WatchAddRejected); ok { + seen++ + } + } + w.mu.Lock() + defer w.mu.Unlock() + if _, ok := w.mirror.scripts[kept]; !ok { + t.Error("a script the node holds left the mirror") + } + if _, ok := w.mirror.scripts[refused]; ok { + t.Error("a refused script is still mirrored and would be replayed on reconnect") + } + if len(w.mirror.prefixes) != 0 { + t.Errorf("the masked echo did not match the caller's prefix: %v", w.mirror.prefixes) + } + if got := w.mirror.descriptors["wpkh(a)"]; got != (descriptorWindow{gapLimit: 20, start: 0}) { + t.Errorf("descriptor window = %+v, want the window the node kept (start 0)", got) + } +} diff --git a/clients/go/watchset.go b/clients/go/watchset.go index 60176196a..26800f9dd 100644 --- a/clients/go/watchset.go +++ b/clients/go/watchset.go @@ -39,6 +39,9 @@ type WatchSet struct { depthAlarms map[depthAlarm]struct{} // descriptors maps a descriptor string to its latest window. descriptors map[string]descriptorWindow + // descriptorPrev maps a descriptor to the window its latest add replaced, + // so a slide the node refuses can fall back to the window the node holds. + descriptorPrev map[string]descriptorWindow // prefixes is the set of registered buckets, keyed by (bits, prefix hex) so // the byte slice does not have to be a map key. prefixes map[prefixKey]ScriptPrefix @@ -131,7 +134,18 @@ func (w *WatchSet) AddDescriptor(descriptor string, gapLimit, start uint32) *Wat if w.descriptors == nil { w.descriptors = map[string]descriptorWindow{} } - w.descriptors[descriptor] = descriptorWindow{gapLimit: gapLimit, start: start} + win := descriptorWindow{gapLimit: gapLimit, start: start} + prev, had := w.descriptors[descriptor] + w.descriptors[descriptor] = win + switch { + case had && prev != win: + if w.descriptorPrev == nil { + w.descriptorPrev = map[string]descriptorWindow{} + } + w.descriptorPrev[descriptor] = prev + case !had: + delete(w.descriptorPrev, descriptor) + } return w } diff --git a/satd-events-client/src/event.rs b/satd-events-client/src/event.rs index 3210f7aba..936ecd4f5 100644 --- a/satd-events-client/src/event.rs +++ b/satd-events-client/src/event.rs @@ -842,11 +842,166 @@ pub enum Event { /// via `SetWatchOptions.include_raw_tx`; `None` otherwise. raw_tx: Option>, }, + /// An incremental watch add (`add_scripts`, `add_outpoints`, …) that the + /// server did **not** register: over the quota, over the per-add rate + /// limit, over a per-connection cap, without `stream:watch`, or malformed. + /// None of the items it names is watched. Items the add re-asserted + /// (already watched) are not named and stay watched. The server sends + /// nothing for an add that registered. + /// + /// [`ResilientWatch`](crate::ResilientWatch) drops the named items from its + /// mirror before handing this on, so a reconnect does not re-register + /// them; re-add them yourself if you want another try (for example after + /// [`retry_after_secs`](WatchAddRejected::retry_after_secs) on a rate limit). + WatchAddRejected(WatchAddRejected), /// A body this client build does not recognize (a newer server arm), or an /// event with no body set. Ignored by well-behaved consumers. Unknown, } +/// The payload of [`Event::WatchAddRejected`]: which add was refused, why, and +/// the items it named. Only the item field for [`kind`](Self::kind) is set. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct WatchAddRejected { + /// Which kind of add was refused. + pub kind: WatchAddKind, + /// Why it was refused. + pub reason: WatchAddRejectReason, + /// [`QuotaExceeded`](WatchAddRejectReason::QuotaExceeded): the units the + /// refused items cost. [`CapExceeded`](WatchAddRejectReason::CapExceeded): + /// the count the add would have reached. 0 otherwise. + pub required: u64, + /// `QuotaExceeded`: the units the token already holds. 0 otherwise. + pub held: u64, + /// `QuotaExceeded`: the token's unit quota. `CapExceeded`: the cap. 0 + /// otherwise. + pub quota: u64, + /// [`RateLimited`](WatchAddRejectReason::RateLimited): seconds until the + /// rate limit admits another add. 0 otherwise. + pub retry_after_secs: u32, + /// Refused scripthashes (32 bytes each). + pub scripthashes: Vec>, + /// Refused outpoints, as `(txid, vout)` (txid in internal byte order). + pub outpoints: Vec<(Vec, u32)>, + /// Refused lifecycle watches, by txid. + pub txids: Vec>, + /// Refused depth alarms, as `(txid, depth)`. + pub depth_alarms: Vec<(Vec, u32)>, + /// The refused descriptor and the window it asked for. + pub descriptor: Option, + /// Refused script prefixes, as `(prefix, bits)` with the prefix masked to + /// `bits`. + pub prefixes: Vec<(Vec, u32)>, + /// Refused silent-payment targets, by scan pubkey `b_scan·G` (33 bytes). + pub scan_pubkeys: Vec>, +} + +/// A descriptor named by a [`WatchAddRejected`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct RejectedDescriptor { + /// The descriptor string the add carried. + pub descriptor: String, + /// The window size the add asked for. + pub gap_limit: u32, + /// The window start the add asked for. + pub start: u32, + /// `true` when an earlier window of this descriptor stays watched (a refused + /// slide); `false` when the descriptor is not watched at all. + pub kept: bool, +} + +/// Which kind of add a [`WatchAddRejected`] refers to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum WatchAddKind { + /// `add_scripts`. + Scripts, + /// `add_outpoints`. + Outpoints, + /// `add_tx_lifecycle`. + Transactions, + /// `add_depth_alarms`. + DepthAlarms, + /// `add_descriptor`. + Descriptor, + /// `add_script_prefixes`. + ScriptPrefixes, + /// `add_silent_payments`. + SilentPayments, + /// A kind code this client build does not recognize (a newer server). + Unknown, +} + +/// Why the server refused an incremental add (see [`Event::WatchAddRejected`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum WatchAddRejectReason { + /// The refused items' unit cost does not fit the token's watch quota. Remove + /// watches, or ask the operator for a larger quota. + QuotaExceeded, + /// The token's per-add rate limit is spent. The same add can succeed after + /// `retry_after_secs`. + RateLimited, + /// A per-connection cap: 16 silent-payment targets, 256 descriptors, or the + /// WebSocket entry cap. Remove watches on this connection first. + CapExceeded, + /// The token lacks the `stream:watch` capability. + PermissionDenied, + /// The add could not be applied as a whole (a `min_values` list not parallel + /// to its scripthashes, an invalid descriptor, too many txid × depth + /// pairs). A client bug: the same add will fail again. + Malformed, + /// A reason code this client build does not recognize (a newer server). + Unknown, +} + +impl WatchAddRejected { + pub(crate) fn from_proto(r: pb::WatchAddRejected) -> Self { + use pb::watch_add_rejected::{Kind, Reason}; + let kind = match Kind::try_from(r.kind) { + Ok(Kind::Scripts) => WatchAddKind::Scripts, + Ok(Kind::Outpoints) => WatchAddKind::Outpoints, + Ok(Kind::Transactions) => WatchAddKind::Transactions, + Ok(Kind::DepthAlarms) => WatchAddKind::DepthAlarms, + Ok(Kind::Descriptor) => WatchAddKind::Descriptor, + Ok(Kind::ScriptPrefixes) => WatchAddKind::ScriptPrefixes, + Ok(Kind::SilentPayments) => WatchAddKind::SilentPayments, + Ok(Kind::Unspecified) | Err(_) => WatchAddKind::Unknown, + }; + let reason = match Reason::try_from(r.reason) { + Ok(Reason::QuotaExceeded) => WatchAddRejectReason::QuotaExceeded, + Ok(Reason::RateLimited) => WatchAddRejectReason::RateLimited, + Ok(Reason::CapExceeded) => WatchAddRejectReason::CapExceeded, + Ok(Reason::PermissionDenied) => WatchAddRejectReason::PermissionDenied, + Ok(Reason::Malformed) => WatchAddRejectReason::Malformed, + Ok(Reason::Unspecified) | Err(_) => WatchAddRejectReason::Unknown, + }; + let descriptor = (kind == WatchAddKind::Descriptor).then(|| RejectedDescriptor { + descriptor: r.descriptor, + gap_limit: r.gap_limit, + start: r.start, + kept: r.descriptor_kept, + }); + WatchAddRejected { + kind, + reason, + required: r.required, + held: r.held, + quota: r.quota, + retry_after_secs: r.retry_after_secs, + scripthashes: r.scripthashes, + outpoints: r.outpoints.into_iter().map(|o| (o.txid, o.vout)).collect(), + txids: r.txids, + depth_alarms: r.depth_alarms.into_iter().map(|d| (d.txid, d.depth)).collect(), + descriptor, + prefixes: r.prefixes.into_iter().map(|p| (p.prefix, p.bits)).collect(), + scan_pubkeys: r.scan_pubkeys, + } + } +} + /// Why a mid-stream re-anchor was declined by the server (see /// [`Event::CursorRejected`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1044,6 +1199,7 @@ impl From for Event { }, None => Event::Unknown, }, + Body::WatchAddRejected(r) => Event::WatchAddRejected(WatchAddRejected::from_proto(r)), Body::RescanResult(r) => match r.outcome { Some(pb::rescan_result::Outcome::Accepted(a)) => Event::RescanAccepted { from_height: a.from_height, diff --git a/satd-events-client/src/lib.rs b/satd-events-client/src/lib.rs index 4908518f6..e82853865 100644 --- a/satd-events-client/src/lib.rs +++ b/satd-events-client/src/lib.rs @@ -148,8 +148,9 @@ pub use client::{ pub use error::StreamError; pub use event::{ display_hex, Cursor, CursorRejectReason, DescriptorMatch, Event, EvictReason, Outpoint, - PrefixMatch, RescanRejectReason, ScriptPrefix, SpentPrevout, StatusKind, StatusSeverity, - StatusState, TaprootOutput, TweakEntry, WatchSetRejectReason, + PrefixMatch, RejectedDescriptor, RescanRejectReason, ScriptPrefix, SpentPrevout, StatusKind, + StatusSeverity, StatusState, TaprootOutput, TweakEntry, WatchAddKind, WatchAddRejectReason, + WatchAddRejected, WatchSetRejectReason, }; /// Parse a raw wire txid into a typed [`bitcoin::Txid`]. Requires the `bitcoin` /// feature. diff --git a/satd-events-client/src/resilient_watch.rs b/satd-events-client/src/resilient_watch.rs index f799cd39e..92ce52734 100644 --- a/satd-events-client/src/resilient_watch.rs +++ b/satd-events-client/src/resilient_watch.rs @@ -34,6 +34,11 @@ //! caller can escalate to a full resnapshot — the exception, not the rule. //! - **Cursor persistence** — confirmed cursors are committed-on-poll to a //! shared [`CursorStore`], so a resume survives reconnects and restarts. +//! - **Refused adds leave the mirror** — when the server answers an add with +//! [`Event::WatchAddRejected`], the items it names are dropped from the mirror +//! (a refused descriptor slide falls back to the window it replaced), so a +//! reconnect re-registers only what the server actually holds. The event is +//! still handed to the caller. use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; @@ -47,7 +52,7 @@ use crate::client::{ validate_prefix, AutoClose, EventStream, SilentPaymentTarget, StreamClient, WatchHandle, }; use crate::error::StreamError; -use crate::event::{Cursor, CursorRejectReason, Event}; +use crate::event::{Cursor, CursorRejectReason, Event, WatchAddRejected}; use crate::resilience::{Backoff, CursorStore, NoopCursorStore}; /// A boxed integrator error returned by a watch-set loader. @@ -87,6 +92,9 @@ pub(crate) struct WatchSetMirror { depth_alarms: BTreeSet<(Txid, u32)>, /// Descriptor → its latest `(gap_limit, start)` window. descriptors: BTreeMap, + /// Descriptor → the window its latest add replaced, so a slide the server + /// refuses can fall back to the window the server still holds. + descriptor_prev: BTreeMap, /// Script-prefix buckets, as `(bits, prefix)` (validated on insert). prefixes: BTreeSet<(u32, Vec)>, /// BIP 352 scan-key targets, keyed by identity `b_scan·G` (33 bytes) — the @@ -148,11 +156,61 @@ impl WatchSetMirror { } fn add_descriptor(&mut self, descriptor: String, gap_limit: u32, start: u32) { - self.descriptors.insert(descriptor, (gap_limit, start)); + let window = (gap_limit, start); + match self.descriptors.insert(descriptor.clone(), window) { + Some(prev) if prev != window => { + self.descriptor_prev.insert(descriptor, prev); + } + Some(_) => {} + None => { + self.descriptor_prev.remove(&descriptor); + } + } } fn remove_descriptor(&mut self, descriptor: &str) { self.descriptors.remove(descriptor); + self.descriptor_prev.remove(descriptor); + } + + /// Drop what the server refused to register, so the mirror holds only what + /// the server holds. Items are named exactly as the server echoes them. + fn forget_rejected(&mut self, r: &WatchAddRejected) { + let fixed = |b: &Vec| <[u8; 32]>::try_from(b.as_slice()).ok(); + let scripts: Vec = r.scripthashes.iter().filter_map(fixed).collect(); + self.remove_scripts(&scripts); + let outpoints: Vec<(Txid, u32)> = + r.outpoints.iter().filter_map(|(t, v)| Some((fixed(t)?, *v))).collect(); + self.remove_outpoints(&outpoints); + let txids: Vec = r.txids.iter().filter_map(fixed).collect(); + self.remove_tx_lifecycle(&txids); + let alarms: Vec<(Txid, u32)> = + r.depth_alarms.iter().filter_map(|(t, d)| Some((fixed(t)?, *d))).collect(); + self.remove_depth_alarms(&alarms); + // The server echoes a prefix masked to its bits; the mirror holds it as + // the caller wrote it. + for (prefix, bits) in &r.prefixes { + self.prefixes.retain(|(b, p)| !(b == bits && mask_prefix(p, *b) == *prefix)); + } + let ids: Vec<[u8; 33]> = + r.scan_pubkeys.iter().filter_map(|b| <[u8; 33]>::try_from(b.as_slice()).ok()).collect(); + self.remove_silent_payments(&ids); + if let Some(d) = &r.descriptor { + // Only the window the refusal names; a later slide already replaced it. + if self.descriptors.get(&d.descriptor) == Some(&(d.gap_limit, d.start)) { + match self.descriptor_prev.remove(&d.descriptor) { + Some(prev) if d.kept => { + self.descriptors.insert(d.descriptor.clone(), prev); + } + // Kept, but the mirror never saw the earlier window (a + // loader-built set): leave the requested one to replay. + None if d.kept => {} + _ => { + self.descriptors.remove(&d.descriptor); + } + } + } + } } fn add_prefixes(&mut self, items: &[pb::ScriptPrefix]) { @@ -1584,6 +1642,9 @@ impl ResilientWatch { // is the exact `(txid, depth)` key to drop; a finalize evicts the whole // lifecycle watch for the txid. match &ev { + // The server refused an add: it does not hold these items, so a + // reconnect must not re-register them. + Event::WatchAddRejected(r) => self.mirror.forget_rejected(r), Event::TxidDepthReached { txid, depth, .. } => { if let Ok(t) = <[u8; 32]>::try_from(txid.as_slice()) { self.mirror.remove_depth_alarms(&[(t, *depth)]); @@ -1788,6 +1849,18 @@ impl ResilientWatch { } } +/// `prefix` with the bits past `bits` cleared, as the server echoes it. +fn mask_prefix(prefix: &[u8], bits: u32) -> Vec { + let mut out = prefix.to_vec(); + let rem = bits % 8; + if rem != 0 + && let Some(last) = out.get_mut((bits / 8) as usize) + { + *last &= 0xffu8 << (8 - rem); + } + out +} + /// `txids × depths` as flattened pairs. fn cross_product(txids: &[Txid], depths: &[u32]) -> Vec<(Txid, u32)> { let mut out = Vec::with_capacity(txids.len() * depths.len()); @@ -2395,6 +2468,129 @@ mod tests { ); } + // --- refused adds leave the mirror ---------------------------------------- + + fn refused(r: pb::WatchAddRejected) -> Event { + Event::WatchAddRejected(WatchAddRejected::from_proto(r)) + } + + #[tokio::test] + async fn refused_adds_are_pruned_from_the_mirror() { + use pb::watch_add_rejected::{Kind, Reason}; + let store = Arc::new(MemStore::default()); + let mut w = watch_with(&store); + w.add_scripts([([1u8; 32], None), ([2u8; 32], None)]).await.unwrap(); + w.add_outpoints([([3u8; 32], 0), ([3u8; 32], 1)]).await.unwrap(); + // 12 bits: the caller's low nibble survives in the mirror, the server + // echoes it masked. + w.add_script_prefixes([(vec![0xab, 0xcd], 12)]).await.unwrap(); + + let out = w + .handle_event( + refused(pb::WatchAddRejected { + kind: Kind::Scripts as i32, + reason: Reason::QuotaExceeded as i32, + scripthashes: vec![vec![2u8; 32]], + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert!(matches!(out, Some(Event::WatchAddRejected(_))), "still surfaced to the caller"); + assert!(w.mirror.scripts.contains_key(&[1u8; 32]), "an item the server holds stays"); + assert!(!w.mirror.scripts.contains_key(&[2u8; 32]), "a refused script is dropped"); + + w.handle_event( + refused(pb::WatchAddRejected { + kind: Kind::Outpoints as i32, + reason: Reason::RateLimited as i32, + outpoints: vec![pb::Outpoint { txid: vec![3u8; 32], vout: 1 }], + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert!(w.mirror.outpoints.contains(&([3u8; 32], 0))); + assert!(!w.mirror.outpoints.contains(&([3u8; 32], 1))); + + w.handle_event( + refused(pb::WatchAddRejected { + kind: Kind::ScriptPrefixes as i32, + reason: Reason::CapExceeded as i32, + prefixes: vec![pb::ScriptPrefix { prefix: vec![0xab, 0xc0], bits: 12 }], + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert!(w.mirror.prefixes.is_empty(), "the masked echo matches the caller's prefix"); + } + + #[tokio::test] + async fn refused_descriptor_slide_falls_back_to_the_window_the_server_holds() { + use pb::watch_add_rejected::{Kind, Reason}; + let store = Arc::new(MemStore::default()); + let mut w = watch_with(&store); + w.add_descriptor("wpkh(a)", 20, 0).await.unwrap(); + w.add_descriptor("wpkh(a)", 20, 20).await.unwrap(); + w.add_descriptor("wpkh(b)", 20, 0).await.unwrap(); + + // The slide of `a` to start 20 is refused; the server keeps start 0. + w.handle_event( + refused(pb::WatchAddRejected { + kind: Kind::Descriptor as i32, + reason: Reason::QuotaExceeded as i32, + descriptor: "wpkh(a)".into(), + gap_limit: 20, + start: 20, + descriptor_kept: true, + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(w.mirror.descriptors.get("wpkh(a)"), Some(&(20, 0)), "replays the held window"); + + // A brand-new descriptor that is refused is not watched at all. + w.handle_event( + refused(pb::WatchAddRejected { + kind: Kind::Descriptor as i32, + reason: Reason::CapExceeded as i32, + descriptor: "wpkh(b)".into(), + gap_limit: 20, + start: 0, + descriptor_kept: false, + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert!(!w.mirror.descriptors.contains_key("wpkh(b)")); + + // A refusal for a window the caller already slid past changes nothing. + w.add_descriptor("wpkh(a)", 20, 40).await.unwrap(); + w.handle_event( + refused(pb::WatchAddRejected { + kind: Kind::Descriptor as i32, + reason: Reason::RateLimited as i32, + descriptor: "wpkh(a)".into(), + gap_limit: 20, + start: 20, + descriptor_kept: true, + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(w.mirror.descriptors.get("wpkh(a)"), Some(&(20, 40))); + } + // --- cursor persistence (commit-on-poll) ---------------------------------- #[tokio::test] diff --git a/satd-events-client/tests/event_mapping.rs b/satd-events-client/tests/event_mapping.rs index 5fa68883d..d2067b182 100644 --- a/satd-events-client/tests/event_mapping.rs +++ b/satd-events-client/tests/event_mapping.rs @@ -4,7 +4,7 @@ use satd_events_client::{ proto as pb, CursorRejectReason, DescriptorMatch, Event, EvictReason, StatusKind, - StatusSeverity, StatusState, + StatusSeverity, StatusState, WatchAddKind, WatchAddRejectReason, }; fn node_event(body: pb::node_event::Body) -> pb::NodeEvent { @@ -202,6 +202,57 @@ fn set_cursor_rejected_maps_reason() { ); } +#[test] +fn watch_add_rejected_maps_reason_numbers_and_items() { + use pb::watch_add_rejected::{Kind, Reason}; + let ev = node_event(pb::node_event::Body::WatchAddRejected(pb::WatchAddRejected { + kind: Kind::Descriptor as i32, + reason: Reason::QuotaExceeded as i32, + required: 20, + held: 90, + quota: 100, + descriptor: "wpkh(xpub...)".into(), + gap_limit: 20, + start: 40, + descriptor_kept: true, + ..Default::default() + })); + let Event::WatchAddRejected(r) = Event::from(ev) else { + panic!("expected WatchAddRejected"); + }; + assert_eq!((r.kind, r.reason), (WatchAddKind::Descriptor, WatchAddRejectReason::QuotaExceeded)); + assert_eq!((r.required, r.held, r.quota), (20, 90, 100)); + let d = r.descriptor.expect("a descriptor refusal names its descriptor"); + assert_eq!((d.descriptor.as_str(), d.gap_limit, d.start, d.kept), ("wpkh(xpub...)", 20, 40, true)); + + let ev = node_event(pb::node_event::Body::WatchAddRejected(pb::WatchAddRejected { + kind: Kind::DepthAlarms as i32, + reason: Reason::RateLimited as i32, + retry_after_secs: 3, + depth_alarms: vec![pb::WatchDepthAlarm { txid: vec![0x22; 32], depth: 6 }], + ..Default::default() + })); + let Event::WatchAddRejected(r) = Event::from(ev) else { + panic!("expected WatchAddRejected"); + }; + assert_eq!((r.kind, r.reason, r.retry_after_secs), (WatchAddKind::DepthAlarms, WatchAddRejectReason::RateLimited, 3)); + assert_eq!(r.depth_alarms, vec![(vec![0x22; 32], 6)]); + assert!(r.descriptor.is_none(), "only the descriptor kind carries a descriptor"); +} + +#[test] +fn watch_add_rejected_unknown_codes_are_unknown() { + let ev = node_event(pb::node_event::Body::WatchAddRejected(pb::WatchAddRejected { + kind: 99, + reason: 99, + ..Default::default() + })); + let Event::WatchAddRejected(r) = Event::from(ev) else { + panic!("expected WatchAddRejected"); + }; + assert_eq!((r.kind, r.reason), (WatchAddKind::Unknown, WatchAddRejectReason::Unknown)); +} + #[test] fn set_cursor_rejected_unknown_reason_is_unknown_variant() { // A reason code from a newer server maps to the catch-all, not a panic. diff --git a/satd/tests/e2e/parity.rs b/satd/tests/e2e/parity.rs index e0b0eaa8e..19a4ecfd4 100644 --- a/satd/tests/e2e/parity.rs +++ b/satd/tests/e2e/parity.rs @@ -502,6 +502,55 @@ mod canonical { ("quota", json!(quota)), ], ), + Event::WatchAddRejected(r) => obj( + "watch_add_rejected", + [ + ("kind", json!(watch_add_kind_name(r.kind))), + ("reason", json!(watch_add_reject_name(r.reason))), + ("required", json!(r.required)), + ("held", json!(r.held)), + ("quota", json!(r.quota)), + ("retry_after_secs", json!(r.retry_after_secs)), + ("scripthashes", json!(r.scripthashes.iter().map(|b| hexs(b)).collect::>())), + ( + "outpoints", + json!(r + .outpoints + .iter() + .map(|(t, v)| json!({ "txid": hexs(t), "vout": v })) + .collect::>()), + ), + ("txids", json!(r.txids.iter().map(|b| hexs(b)).collect::>())), + ( + "depth_alarms", + json!(r + .depth_alarms + .iter() + .map(|(t, d)| json!({ "txid": hexs(t), "depth": d })) + .collect::>()), + ), + ( + "descriptor", + r.descriptor.as_ref().map_or(Value::Null, |d| { + json!({ + "descriptor": d.descriptor, + "gap_limit": d.gap_limit, + "start": d.start, + "kept": d.kept, + }) + }), + ), + ( + "prefixes", + json!(r + .prefixes + .iter() + .map(|(p, bits)| json!({ "prefix": hexs(p), "bits": bits })) + .collect::>()), + ), + ("scan_pubkeys", json!(r.scan_pubkeys.iter().map(|b| hexs(b)).collect::>())), + ], + ), Event::RescanAccepted { from_height, to_height, clamped } => obj( "rescan_accepted", [ @@ -681,6 +730,34 @@ enum_namer!( } ); +enum_namer!( + watch_add_kind_name, + satd_events_client::WatchAddKind, + pb::watch_add_rejected::Kind, + { + Scripts => Scripts, + Outpoints => Outpoints, + Transactions => Transactions, + DepthAlarms => DepthAlarms, + Descriptor => Descriptor, + ScriptPrefixes => ScriptPrefixes, + SilentPayments => SilentPayments, + } +); + +enum_namer!( + watch_add_reject_name, + satd_events_client::WatchAddRejectReason, + pb::watch_add_rejected::Reason, + { + QuotaExceeded => QuotaExceeded, + RateLimited => RateLimited, + CapExceeded => CapExceeded, + PermissionDenied => PermissionDenied, + Malformed => Malformed, + } +); + enum_namer!( rescan_reject_name, satd_events_client::RescanRejectReason, diff --git a/satd/tests/e2e/streaming.rs b/satd/tests/e2e/streaming.rs index 34f5276af..341aca878 100644 --- a/satd/tests/e2e/streaming.rs +++ b/satd/tests/e2e/streaming.rs @@ -1588,6 +1588,74 @@ async fn ws_max_conns_refuses_second() { assert!(second.is_err(), "second ws connection over the cap is refused"); } +/// An add the token's watch quota cannot hold is answered in-band with a +/// `WatchAddRejected` naming the refused items, and the stream stays up. An add +/// that fits produces no event: the next event after it is the following add's +/// refusal, whose `held` counts the unit the accepted add took. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn grpc_watch_over_quota_add_is_rejected_in_band() { + use satd_events::proto::v1::watch_add_rejected::{Kind, Reason}; + let fixture = write_authfile(&[TokenSpec { + id: "watcher", + token: "tok-watch-1", + capabilities: &["stream:subscribe", "stream:watch"], + rate_limit: None, + watch_quota: Some(1), + }]); + let autharg = format!("--authfile={}", fixture.authfile.display()); + let sn = start_streaming_args(vec![autharg, "--events-grpc-auth=1".into()], vec![]).await; + let mut client = GrpcStreamClient::connect_with_token(sn.grpc_port(), "tok-watch-1").await; + + let a = "aa".repeat(32); + let b = "bb".repeat(32); + let (tx, mut stream) = client.watch(vec![add_scripts(&[&a, &b])]).await; + let ev = next_event_matching(&mut stream, 15, |x| matches!(x, Body::WatchAddRejected(_))).await; + let Some(Body::WatchAddRejected(r)) = ev.body else { + unreachable!() + }; + assert_eq!((r.kind, r.reason), (Kind::Scripts as i32, Reason::QuotaExceeded as i32)); + assert_eq!((r.required, r.held, r.quota), (2, 0, 1)); + assert_eq!(r.scripthashes.iter().map(hex::encode).collect::>(), vec![a.clone(), b.clone()]); + + tx.send(add_scripts(&[&a])).await.expect("send add"); + tx.send(add_scripts(&[&b])).await.expect("send add"); + let ev = next_event_matching(&mut stream, 15, |x| matches!(x, Body::WatchAddRejected(_))).await; + let Some(Body::WatchAddRejected(r)) = ev.body else { + unreachable!() + }; + assert_eq!((r.required, r.held, r.quota), (1, 1, 1), "the add that fit holds the unit"); + assert_eq!(r.scripthashes.iter().map(hex::encode).collect::>(), vec![b]); +} + +/// The WebSocket per-connection entry cap refuses an add in-band with a +/// `watch_add_rejected` frame, echoing the refused txid in the display hex the +/// add used. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ws_add_over_entry_cap_is_rejected_in_band() { + let sn = start_streaming_args( + vec![ + "--events-grpc-bind=127.0.0.1:0".into(), + "--streamws=127.0.0.1:0".into(), + "--streamws-max-subscriptions=1".into(), + ], + vec![], + ) + .await; + let mut ws = WsClient::connect(sn.ws_port()).await; + let first = "11".repeat(32); + let second = format!("{}{}", "22".repeat(31), "01"); + ws.send_control(serde_json::json!({ "type": "add_transactions", "txids": [first] })).await; + ws.send_control(serde_json::json!({ "type": "add_transactions", "txids": [second] })).await; + let ev = ws + .next_json_matching(15, |v| v["body"]["category"] == "watch_add_rejected") + .await; + let body = &ev["body"]; + assert_eq!(body["kind"], "transactions"); + assert_eq!(body["reason"], "cap_exceeded"); + assert_eq!((body["required"].as_u64(), body["quota"].as_u64()), (Some(2), Some(1))); + assert_eq!(body["txids"], serde_json::json!([second])); +} + // =========================================================================== // Phase 6: consensus invariant — the event bus never backpressures consensus // =========================================================================== From 01e0b6c46614d644f91031299ff326b87bf0aef4 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 15:02:58 -0600 Subject: [PATCH 3/9] docs: describe WatchAddRejected; RESOURCE_EXHAUSTED is only at stream open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming spec gains §7.3.2 on the new event; the quota section, the operator manual (streaming, authentication, Rust and Go SDK chapters) and both SDKs' QuotaExhausted docs stop promising RESOURCE_EXHAUSTED / 429 for an over-quota add and point at the event. CHANGELOG and the release notes describe the fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/go/errors.go | 8 ++-- docs/api/streaming.md | 63 +++++++++++++++++++++++++++++-- docs/manual/src/authentication.md | 5 ++- docs/manual/src/go-sdk.md | 8 ++++ docs/manual/src/rust-sdk.md | 15 ++++++-- docs/manual/src/streaming.md | 18 ++++++--- satd-events-client/src/error.rs | 8 ++-- 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/clients/go/errors.go b/clients/go/errors.go index 7ed1118e0..217a746c1 100644 --- a/clients/go/errors.go +++ b/clients/go/errors.go @@ -35,10 +35,10 @@ const ( // the required capability (stream:subscribe to open, stream:watch to add // watches). A permanent configuration error. KindPermissionDenied - // KindQuotaExhausted is gRPC RESOURCE_EXHAUSTED: the subscription cap, a - // per-principal rate limit, or the per-token watch quota. The first two are - // transient; a genuinely full watch quota is not. Inspect Status's message - // to distinguish. + // KindQuotaExhausted is gRPC RESOURCE_EXHAUSTED: the subscription cap or a + // per-principal rate limit, hit when the stream was opened. Both are + // transient. A full watch quota never surfaces here; a refused add arrives + // as a WatchAddRejected event. KindQuotaExhausted // KindRateLimited is reserved for explicit rate-limit signaling in the // resilience layer. The current server does not return a status for an diff --git a/docs/api/streaming.md b/docs/api/streaming.md index 0b6b5592f..a2d1ad4da 100644 --- a/docs/api/streaming.md +++ b/docs/api/streaming.md @@ -154,6 +154,7 @@ message NodeEvent { SilentPaymentMatched silent_payment_matched = 29; // BIP 352 scan-key watch match (§7.7) MempoolTweak mempool_tweak = 30; // BIP 352 mempool-time tweak, Tier 1.5 (§7.7) StatusEvent status = 31; // node-health condition (§7.8) + WatchAddRejected watch_add_rejected = 32; // an incremental watch add the node did not register (§7.3.2) } } ``` @@ -508,6 +509,57 @@ state machine off these results rather than treating the `SetCursor` send as success. WS/SSE clients have no mid-stream control channel and so never see a `SetCursorResult` — they re-anchor by reconnecting with `?from_cursor=`. +#### 7.3.2 Refused watch adds (`WatchAddRejected`) + +An incremental `Add*` that the server does not register is answered in-band with +one `WatchAddRejected` event, and the stream stays up. An add that registers gets +no event, so a client that sees none can rely on its adds having landed. The add +is all-or-nothing over its **net-new** items: none of the items the event names is +watched. Items the message re-asserted (already watched) are not named and stay +watched; their metadata (a script's `min_value` floor, a silent-payment target's +labels) still updates, except when a silent-payment add is refused for its cap or +the rate limit. + +```proto +message WatchAddRejected { + enum Kind { KIND_UNSPECIFIED = 0; SCRIPTS = 1; OUTPOINTS = 2; TRANSACTIONS = 3; + DEPTH_ALARMS = 4; DESCRIPTOR = 5; SCRIPT_PREFIXES = 6; SILENT_PAYMENTS = 7; } + enum Reason { REASON_UNSPECIFIED = 0; QUOTA_EXCEEDED = 1; RATE_LIMITED = 2; + CAP_EXCEEDED = 3; PERMISSION_DENIED = 4; MALFORMED = 5; } + Kind kind = 1; Reason reason = 2; + uint64 required = 3; uint64 held = 4; uint64 quota = 5; uint32 retry_after_secs = 6; + // The refused items, as the client named them; only the field for `kind` is set. + repeated bytes scripthashes = 7; repeated Outpoint outpoints = 8; repeated bytes txids = 9; + repeated WatchDepthAlarm depth_alarms = 10; + string descriptor = 11; uint32 gap_limit = 12; uint32 start = 13; bool descriptor_kept = 14; + repeated ScriptPrefix prefixes = 15; // masked to `bits` + repeated bytes scan_pubkeys = 16; // b_scan·G; the scan secret is never echoed +} +``` + +| `reason` | Cause | Numbers | +|---|---|---| +| `QUOTA_EXCEEDED` | the net-new items' units do not fit the token's watch quota (§9) | `required` units, `held` units already held, `quota` ceiling | +| `RATE_LIMITED` | the per-principal add rate limit is spent | `retry_after_secs` | +| `CAP_EXCEEDED` | a per-connection cap: 16 silent-payment targets, 256 descriptors, or the WS entry cap (`streamwsmaxsubscriptions`) | `required` = the count the add would reach, `quota` = the cap | +| `PERMISSION_DENIED` | the token lacks `stream:watch` | none | +| `MALFORMED` | the message cannot be applied as a whole: `min_values` not parallel to its scripthashes, an invalid descriptor, or a txid × depth product over the per-message cap | none | + +`MALFORMED` echoes the items that parsed, except for an over-cap depth product, +which echoes none. Individually unparseable items inside an otherwise valid add (a +scripthash that is not 32 bytes, a prefix outside the allowed bit range, an +invalid silent-payment key) are skipped without a rejection. + +A refused `AddDescriptor` names the descriptor and the window it asked for. +`descriptor_kept` is true when an earlier window of that descriptor stays watched +(a refused slide) and false when the descriptor is not watched at all. + +On WS the event is the JSON object `{"category": "watch_add_rejected", "kind": +"scripts", "reason": "quota_exceeded", "required", "held", "quota", +"retry_after_secs", ...}` with the items under the same field names. Items come back +the way a WS add names them: scripthashes and scan pubkeys as plain hex, txids in +display (reversed) hex. + ### 7.4 Transaction lifecycle & confirmation-depth watches `AddTransactions` registers, over the same `by_txid` index, **two decoupled @@ -1167,9 +1219,12 @@ prefix watch (§7.5), which is priced by coarseness — units scale inversely wi `bits` (a coarser bucket delivers more traffic) rather than a flat one unit — so the quota reflects served bandwidth, not item count, for the one watch kind whose cost is not one-item-one-match. This bounds the *work* a tenant pins on the node, not the -message count. Over-quota adds are rejected -cleanly (`RESOURCE_EXHAUSTED` on gRPC / `429` on WS) without tearing down the -subscription. +message count. An over-quota add is refused in-band with a `WatchAddRejected` +(`QUOTA_EXCEEDED`, §7.3.2) naming the refused items, without tearing down the +subscription; so is an add the per-add rate limit throttles (`RATE_LIMITED`). +`RESOURCE_EXHAUSTED` (gRPC) and `429` (WS) are returned only when a stream is +opened. A `SetWatchSet` replace reports its own outcome as `WatchSetResult` +(§11.2). **Lease lifecycle.** Each item holds a `WatchLease` (RAII) tracked **per item** in a subscription-scoped `WatchSet`. So `Remove*` returns that item's unit @@ -1382,7 +1437,7 @@ exhaustion, mirroring the rest of the node's admission controls. All are | Key | Default | Bounds | |---|---|---| | `streamwsmaxconns` | 256 | concurrent `/ws` + `/sse` connections | -| `streamwsmaxsubscriptions` | 256 | watch-set size per WS connection | +| `streamwsmaxsubscriptions` | 256 | watch-set size per WS connection; an add past it is refused with `watch_add_rejected` (`cap_exceeded`) | | `streamwsmaxmessagebytes` | 262144 | a single inbound WS control frame | | `eventsgrpcmaxconns` | 64 | concurrent gRPC streams | | `eventsgrpcmaxsubscriptions` | 256 | watch-set size per gRPC stream | diff --git a/docs/manual/src/authentication.md b/docs/manual/src/authentication.md index 6a3382f4b..d861d352e 100644 --- a/docs/manual/src/authentication.md +++ b/docs/manual/src/authentication.md @@ -198,8 +198,9 @@ never authenticate. - **Watch quota.** The streaming watch-set is metered in units. One scripthash costs one unit, and prefix watches are priced by coarseness. A token holds units through an RAII lease, so a disconnect releases its - quota automatically. A watch add over quota is rejected without tearing - down the subscription. + quota automatically. A watch add over quota is refused with an in-band + `WatchAddRejected` event naming the refused items, without tearing down the + subscription. Operator and loopback principals are unlimited. diff --git a/docs/manual/src/go-sdk.md b/docs/manual/src/go-sdk.md index 6d9cee868..8bb3000f9 100644 --- a/docs/manual/src/go-sdk.md +++ b/docs/manual/src/go-sdk.md @@ -227,6 +227,14 @@ stream, and the actual outcome arrives on the event stream as exactly one `CursorAccepted`/`CursorRejected` or `WatchSetReplaced`/`WatchSetRejected`. Drive catch-up off those events, not off the return value. +The incremental `Add*` calls work the same way. One the node does not register +(over the quota or the rate limit, over a per-connection cap, without +`stream:watch`, or malformed) is answered with one `*WatchAddRejected` event +naming the kind, the reason, the numbers behind it and the refused items; none of +those items is watched. An add that registers produces no event. +`ResilientWatch` drops the refused items from its mirror before handing the event +on, so a reconnect does not re-register them. + ## Durable watch: `ResilientWatch` ```go diff --git a/docs/manual/src/rust-sdk.md b/docs/manual/src/rust-sdk.md index 9741e2bef..e8c61527d 100644 --- a/docs/manual/src/rust-sdk.md +++ b/docs/manual/src/rust-sdk.md @@ -466,9 +466,18 @@ retention tiers, which govern what the spend side carries. (`PermissionDenied`, a bad URL or token, client-side argument errors). `Unauthenticated` is reported non-retryable: re-auth and reconnect rather than blind-retrying the same token. `QuotaExhausted` is treated as retryable -because its common causes, the subscription cap and the per-principal rate -limit, are transient. A full watch quota is not transient, so inspect the -boxed status message before retrying a watch-add forever. +because both of its causes, the subscription cap and the per-principal rate +limit when a stream is opened, are transient. + +A watch add the server refuses is not an error. It arrives on `next()` as +`Event::WatchAddRejected`, which names the kind, the reason (`QuotaExceeded`, +`RateLimited`, `CapExceeded`, `PermissionDenied` or `Malformed`), the numbers +behind it and the refused items; none of those items is watched. An add that +registers produces no event. `ResilientWatch` drops the refused items from its +mirror before handing the event on, so a reconnect does not re-register them +(a refused descriptor slide falls back to the window the server kept). Re-add +them yourself if you want another try, for example after `retry_after_secs` on +a rate limit. ## Stability & versioning diff --git a/docs/manual/src/streaming.md b/docs/manual/src/streaming.md index 95af5c8e7..df815b7d2 100644 --- a/docs/manual/src/streaming.md +++ b/docs/manual/src/streaming.md @@ -64,8 +64,11 @@ scripts, outpoints, transactions, script prefixes, and descriptors, and `SetWatchSet`. `SetWatchSet` is an atomic whole-set replace: the client sends the complete desired watch-set in one message, and the server reconciles it under its lock by effective coverage, replying with a deterministic -`WatchSetResult`. New subscription kinds can be added without protocol -breakage. +`WatchSetResult`. An incremental `Add*` the server does not register (over the +quota or the rate limit, over a per-connection cap, without `stream:watch`, or +malformed) is answered with one `WatchAddRejected` event that names the refused +items; an add that registers gets no event. New subscription kinds can be added +without protocol breakage. Match events delivered on the per-subscriber `Watch` channel include: @@ -216,9 +219,12 @@ requires a token store (`-streamwsauth` / `-eventsgrpcauth`, backed by The quota unit is one watched item; N items cost N units. Each item holds an RAII `WatchLease`, so `Remove*` returns its unit immediately, and a long-lived -client can rotate a sliding watch-set without exhausting quota. Over-quota adds -are rejected (`RESOURCE_EXHAUSTED` on gRPC, `429` on WebSocket) without tearing -down the subscription. +client can rotate a sliding watch-set without exhausting quota. An over-quota +add is refused with a `WatchAddRejected` event (`QUOTA_EXCEEDED`, with the units +required, held and allowed) without tearing down the subscription, and so is an +add the per-add rate limit throttles (`RATE_LIMITED`, with a retry-after hint). +`RESOURCE_EXHAUSTED` (gRPC) and `429` (WebSocket) are returned only when a stream +is opened. ## Transport encryption (events gRPC TLS / mTLS) @@ -277,7 +283,7 @@ restart-classified; `0` means unlimited. |---|---|---| | `streamwsmaxconns` | 256 | concurrent `/ws` + `/sse` connections | | `streamwsmaxsockets` | 1024 | open sockets on the listener, counted at accept and held for the socket's life, an open WebSocket included (`0` disables) | -| `streamwsmaxsubscriptions` | 256 | watch-set size per WS connection | +| `streamwsmaxsubscriptions` | 256 | watch-set size per WS connection; an add past it is refused with `watch_add_rejected` | | `streamwsmaxmessagebytes` | 262144 | a single inbound WS control frame | | `eventsgrpcmaxconns` | 64 | concurrent gRPC streams | | `eventsgrpcmaxsubscriptions` | 256 | watch-set size per gRPC stream | diff --git a/satd-events-client/src/error.rs b/satd-events-client/src/error.rs index ab9d8d46f..24ffb8e2f 100644 --- a/satd-events-client/src/error.rs +++ b/satd-events-client/src/error.rs @@ -57,10 +57,10 @@ pub enum StreamError { #[error("permission denied: {0}")] PermissionDenied(#[source] Box), - /// The server's subscription cap, a per-principal rate limit, or the - /// per-token watch quota was hit (gRPC `RESOURCE_EXHAUSTED`). The first two - /// are transient (back off and retry); a genuinely full watch quota is not. - /// Inspect the boxed status message to distinguish. + /// The server's subscription cap or a per-principal rate limit was hit when + /// the stream was opened (gRPC `RESOURCE_EXHAUSTED`). Both are transient: + /// back off and retry. A full watch quota never surfaces here; a refused + /// add arrives as [`Event::WatchAddRejected`](crate::Event::WatchAddRejected). #[error("resource exhausted: {0}")] QuotaExhausted(#[source] Box), From a4a7b2e012ffb04628d247837d985acbdd07d8bd Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 15:08:40 -0600 Subject: [PATCH 4/9] satd-events-client: build the rejected descriptor with then_some Co-Authored-By: Claude Opus 5.5 (1M context) --- satd-events-client/src/event.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/satd-events-client/src/event.rs b/satd-events-client/src/event.rs index 936ecd4f5..7ac10970b 100644 --- a/satd-events-client/src/event.rs +++ b/satd-events-client/src/event.rs @@ -978,7 +978,7 @@ impl WatchAddRejected { Ok(Reason::Malformed) => WatchAddRejectReason::Malformed, Ok(Reason::Unspecified) | Err(_) => WatchAddRejectReason::Unknown, }; - let descriptor = (kind == WatchAddKind::Descriptor).then(|| RejectedDescriptor { + let descriptor = (kind == WatchAddKind::Descriptor).then_some(RejectedDescriptor { descriptor: r.descriptor, gap_limit: r.gap_limit, start: r.start, From 4106005e59165ffaa0101e4d83f682bed74d3da5 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 15:49:27 -0600 Subject: [PATCH 5/9] SDKs re-send a rate-limited watch add instead of dropping it A rate limit is transient, so ResilientWatch keeps the items of a RATE_LIMITED WatchAddRejected in its mirror and re-sends them after the node's retry_after_secs or the backoff delay, whichever is later, absorbing the event. Each item has its own retry budget (the backoff's max_retries); once one is spent the add is handled like any other refusal: dropped from the mirror and handed to the caller. The re-send is built from the mirror at send time, so a removal since the refusal wins. A reconnect re-sends the whole mirror and resets the budgets. Rust drives the retries from next(), racing the stream read against the earliest deadline (EventStream::message is cancel-safe). Go schedules a timer that re-checks it is still on the same stream and sends under mu, as caller edits do. Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/go/events.go | 10 +- clients/go/mirror.go | 115 +++++++++ clients/go/resilientwatch.go | 76 +++++- clients/go/resilientwatch_test.go | 72 +++++- docs/manual/src/go-sdk.md | 6 +- docs/manual/src/rust-sdk.md | 14 +- satd-events-client/src/event.rs | 10 +- satd-events-client/src/resilient_watch.rs | 274 +++++++++++++++++++++- 8 files changed, 548 insertions(+), 29 deletions(-) diff --git a/clients/go/events.go b/clients/go/events.go index f43c5b535..3f6398b4d 100644 --- a/clients/go/events.go +++ b/clients/go/events.go @@ -601,10 +601,12 @@ type WatchSetRejected struct { // are not named and stay watched. The node sends nothing for an add that // registered. // -// [ResilientWatch] drops the named items from its mirror before handing this -// on, so a reconnect does not re-register them; re-add them yourself if you -// want another try (for example after RetryAfterSecs on a rate limit). Only the -// item field for Kind is set. +// [ResilientWatch] re-sends a [WatchAddRejectRateLimited] add itself after the +// node's retry hint, within its backoff budget, and hands the event on only once +// that budget runs out. For any other reason, or then, it drops the named items +// from its mirror before handing this on, so a reconnect does not re-register +// them; re-add them yourself if you want another try. Only the item field for +// Kind is set. type WatchAddRejected struct { // Kind is which kind of add was refused. Kind WatchAddKind diff --git a/clients/go/mirror.go b/clients/go/mirror.go index bedd05475..4c428e2b0 100644 --- a/clients/go/mirror.go +++ b/clients/go/mirror.go @@ -47,6 +47,121 @@ func (w *WatchSet) removeDescriptor(descriptor string) { delete(w.descriptorPrev, descriptor) } +// subsetFor is the part of the mirror a refusal names, for re-sending it: only +// items the caller still holds (a removal since the refusal wins), with their +// current metadata. A descriptor is included only at the window the refusal +// named; a later slide already replaced it. +func (w *WatchSet) subsetFor(r *WatchAddRejected) *WatchSet { + out := NewWatchSet() + for _, b := range r.Scripthashes { + if h, ok := txid32(b); ok { + if floor, held := w.scripts[h]; held { + if out.scripts == nil { + out.scripts = map[[32]byte]*uint64{} + } + out.scripts[h] = floor + } + } + } + for _, o := range r.Outpoints { + if t, ok := txid32(o.Txid); ok { + op := OutpointRef{Txid: t, Vout: o.Vout} + if _, held := w.outpoints[op]; held { + if out.outpoints == nil { + out.outpoints = map[OutpointRef]struct{}{} + } + out.outpoints[op] = struct{}{} + } + } + } + for _, b := range r.Txids { + if t, ok := txid32(b); ok { + if close, held := w.lifecycles[t]; held { + if out.lifecycles == nil { + out.lifecycles = map[[32]byte]AutoClose{} + } + out.lifecycles[t] = close + } + } + } + for _, a := range r.DepthAlarms { + if t, ok := txid32(a.Txid); ok { + key := depthAlarm{txid: t, depth: a.Depth} + if _, held := w.depthAlarms[key]; held { + if out.depthAlarms == nil { + out.depthAlarms = map[depthAlarm]struct{}{} + } + out.depthAlarms[key] = struct{}{} + } + } + } + for _, p := range r.Prefixes { + key := prefixKey{bits: p.Bits, prefix: string(maskPrefixSafe(p.Prefix, p.Bits))} + if held, ok := w.prefixes[key]; ok { + if out.prefixes == nil { + out.prefixes = map[prefixKey]ScriptPrefix{} + } + out.prefixes[key] = held + } + } + for _, b := range r.ScanPubkeys { + var id [33]byte + if len(b) != len(id) { + continue + } + copy(id[:], b) + if t, held := w.silentPayments[id]; held { + if out.silentPayments == nil { + out.silentPayments = map[[33]byte]SilentPaymentTarget{} + } + out.silentPayments[id] = t + } + } + if d := r.Descriptor; d != nil { + win := descriptorWindow{gapLimit: d.GapLimit, start: d.Start} + if cur, held := w.descriptors[d.Descriptor]; held && cur == win { + out.descriptors = map[string]descriptorWindow{d.Descriptor: win} + } + } + return out +} + +// retryKeys is one identity per item a refusal names, for the per-item retry +// budget. The first byte is the kind, so items of different kinds never collide. +func retryKeys(r *WatchAddRejected) []string { + var keys []string + tagged := func(tag byte, parts ...[]byte) string { + b := []byte{tag} + for _, p := range parts { + b = append(b, p...) + } + return string(b) + } + be32 := func(v uint32) []byte { return []byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)} } + for _, b := range r.Scripthashes { + keys = append(keys, tagged(1, b)) + } + for _, o := range r.Outpoints { + keys = append(keys, tagged(2, o.Txid, be32(o.Vout))) + } + for _, b := range r.Txids { + keys = append(keys, tagged(3, b)) + } + for _, a := range r.DepthAlarms { + keys = append(keys, tagged(4, a.Txid, be32(a.Depth))) + } + if r.Descriptor != nil { + keys = append(keys, tagged(5, []byte(r.Descriptor.Descriptor))) + } + for _, p := range r.Prefixes { + keys = append(keys, tagged(6, p.Prefix, be32(p.Bits))) + } + for _, b := range r.ScanPubkeys { + keys = append(keys, tagged(7, b)) + } + return keys +} + // forgetRejected drops what the node refused to register, so the mirror holds // only what the node holds. Items are named exactly as the node echoes them; a // prefix comes back masked, which removeScriptPrefixes matches. diff --git a/clients/go/resilientwatch.go b/clients/go/resilientwatch.go index 097506fe2..9cb52cdc3 100644 --- a/clients/go/resilientwatch.go +++ b/clients/go/resilientwatch.go @@ -5,6 +5,7 @@ import ( "errors" "io" "sync" + "time" "github.com/epochbtc/satd/clients/go/eventspb" ) @@ -192,6 +193,10 @@ type ResilientWatch struct { // reanchorAttempts counts consecutive transient re-anchor rejections, driving // the in-place retry backoff. reanchorAttempts uint32 + // addRetryAttempts counts the retries each rate-limited add item has spent + // (keyed by retryKeys), against the backoff budget. Cleared on reconnect, + // which re-sends the whole mirror anyway. + addRetryAttempts map[string]uint32 // reloadRollback is the mirror as it stood before an in-flight Reload's // SetWatchSet, restored if the node rejects it. Nil when none is in flight. reloadRollback *WatchSet @@ -595,6 +600,66 @@ func (w *ResilientWatch) editErr(ctx context.Context, send func(*WatchHandle) er func (w *ResilientWatch) teardownLocked() { w.handle = nil + // The reconnect re-sends the whole mirror, rate-limited items included, so + // their budgets start over; a pending retry for the old stream does nothing. + w.addRetryAttempts = nil +} + +// scheduleAddRetryLocked arranges for the items of a rate-limited add to be +// re-sent after the node's hint or the backoff delay, whichever is later. It +// reports false, scheduling nothing, once an item has spent the budget. Called +// with mu held. +func (w *ResilientWatch) scheduleAddRetryLocked(ctx context.Context, e *WatchAddRejected, backoff Backoff) bool { + keys := retryKeys(e) + var attempt uint32 + for _, k := range keys { + if a := w.addRetryAttempts[k]; a > attempt { + attempt = a + } + } + if backoff.MaxRetries > 0 && attempt >= backoff.MaxRetries { + for _, k := range keys { + delete(w.addRetryAttempts, k) + } + return false + } + if w.addRetryAttempts == nil { + w.addRetryAttempts = map[string]uint32{} + } + for _, k := range keys { + w.addRetryAttempts[k] = attempt + 1 + } + delay := backoff.DelayFor(attempt) + if hint := time.Duration(e.RetryAfterSecs) * time.Second; hint > delay { + delay = hint + } + h := w.handle + time.AfterFunc(delay, func() { w.resendRefused(ctx, h, e) }) + return true +} + +// resendRefused re-sends what the mirror still holds of a rate-limited add, on +// the stream it was refused on. A removal since the refusal wins, and a stream +// replaced in the meantime got the whole mirror on reconnect. mu is held across +// the send, as for caller edits, so a concurrent edit cannot reorder with it. +func (w *ResilientWatch) resendRefused(ctx context.Context, h *WatchHandle, e *WatchAddRejected) { + w.mu.Lock() + defer w.mu.Unlock() + if h == nil || w.handle != h { + return + } + msgs, err := w.mirror.subsetFor(e).controlMessages() + if err != nil { + return + } + for _, m := range msgs { + if err := h.SendControl(ctx, m); err != nil { + if errors.Is(err, ErrControlClosed) { + w.teardownLocked() + } + return + } + } } func (w *ResilientWatch) commitDue(ctx context.Context) error { @@ -751,8 +816,15 @@ func (w *ResilientWatch) handleEvent(ctx context.Context, ev Event, cur *Cursor, // alarm's identity), so that is the exact key to drop. switch e := ev.(type) { case *WatchAddRejected: - // The node refused an add: it does not hold these items, so a reconnect - // must not re-register them. + // A rate limit is transient: keep the items and re-send them once the + // limit allows, absorbing the event, until an item has spent the backoff + // budget. + if e.Reason == WatchAddRejectRateLimited && w.scheduleAddRetryLocked(ctx, e, backoff) { + w.mu.Unlock() + return true, nil + } + // Otherwise the node does not hold these items, so a reconnect must not + // re-register them. w.mirror.forgetRejected(e) case *TxidDepthReached: if t, ok := txid32(e.Txid); ok { diff --git a/clients/go/resilientwatch_test.go b/clients/go/resilientwatch_test.go index 0a817234e..1ae00a782 100644 --- a/clients/go/resilientwatch_test.go +++ b/clients/go/resilientwatch_test.go @@ -1,6 +1,7 @@ package satdevents import ( + "bytes" "context" "errors" "io" @@ -1138,7 +1139,7 @@ func TestRefusedAddsArePrunedFromTheMirror(t *testing.T) { }, { Kind: eventspb.WatchAddRejected_DESCRIPTOR, - Reason: eventspb.WatchAddRejected_RATE_LIMITED, + Reason: eventspb.WatchAddRejected_QUOTA_EXCEEDED, Descriptor_: "wpkh(a)", GapLimit: 20, Start: 20, DescriptorKept: true, }, } { @@ -1194,3 +1195,72 @@ func TestRefusedAddsArePrunedFromTheMirror(t *testing.T) { t.Errorf("descriptor window = %+v, want the window the node kept (start 0)", got) } } + +// TestRateLimitedAddIsReSent: a rate limit is transient, so the refused items +// stay in the mirror and the SDK re-sends them once the node's hint allows. The +// refusal is absorbed: Next never sees it, only the next real event. +func TestRateLimitedAddIsReSent(t *testing.T) { + kept := [32]byte{0x01} + throttled := [32]byte{0x02} + resent := make(chan *eventspb.AddScripts, 1) + client, _ := startScriptedWatch(t, + func(l *watchLeg, s eventspb.NodeEventStream_WatchServer) error { + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && len(l.controls()) < 1 { + time.Sleep(time.Millisecond) + } + if err := s.Send(&eventspb.NodeEvent{Body: &eventspb.NodeEvent_WatchAddRejected{ + WatchAddRejected: &eventspb.WatchAddRejected{ + Kind: eventspb.WatchAddRejected_SCRIPTS, + Reason: eventspb.WatchAddRejected_RATE_LIMITED, + Scripthashes: [][]byte{append([]byte(nil), throttled[:]...)}, + }, + }}); err != nil { + return err + } + // Wait for the re-send, then release the caller with a real event. + for time.Now().Before(deadline) && len(l.controls()) < 2 { + time.Sleep(time.Millisecond) + } + if c := l.controls(); len(c) >= 2 { + resent <- c[1].GetAddScripts() + } + if err := s.Send(&eventspb.NodeEvent{Body: &eventspb.NodeEvent_Heartbeat{Heartbeat: &eventspb.Heartbeat{}}}); err != nil { + return err + } + <-s.Context().Done() + return nil + }, + parkLeg, + ) + w := client.ResilientWatch(context.Background(), ResilientWatchConfig{ + Backoff: Backoff{Initial: time.Millisecond, Max: 5 * time.Millisecond, Multiplier: 2, MaxRetries: 3}, + }) + defer func() { _ = w.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := w.AddScripts(ctx, ScriptWatch{Scripthash: kept}, ScriptWatch{Scripthash: throttled}); err != nil { + t.Fatal(err) + } + ev, err := w.Next(ctx) + if err != nil { + t.Fatal(err) + } + if _, ok := ev.(*Heartbeat); !ok { + t.Fatalf("first event = %T, want the heartbeat: a rate-limited refusal is absorbed while it retries", ev) + } + select { + case a := <-resent: + if len(a.GetScripthashes()) != 1 || !bytes.Equal(a.GetScripthashes()[0], throttled[:]) { + t.Errorf("re-sent %x, want only the throttled script", a.GetScripthashes()) + } + default: + t.Fatal("the throttled script was never re-sent") + } + w.mu.Lock() + defer w.mu.Unlock() + if _, ok := w.mirror.scripts[throttled]; !ok { + t.Error("a rate-limited script left the mirror") + } +} diff --git a/docs/manual/src/go-sdk.md b/docs/manual/src/go-sdk.md index 8bb3000f9..a5ad483f3 100644 --- a/docs/manual/src/go-sdk.md +++ b/docs/manual/src/go-sdk.md @@ -232,8 +232,10 @@ The incremental `Add*` calls work the same way. One the node does not register `stream:watch`, or malformed) is answered with one `*WatchAddRejected` event naming the kind, the reason, the numbers behind it and the refused items; none of those items is watched. An add that registers produces no event. -`ResilientWatch` drops the refused items from its mirror before handing the event -on, so a reconnect does not re-register them. +`ResilientWatch` re-sends a rate-limited add itself after the node's retry hint, +within its backoff budget, and absorbs the event until that budget runs out. +Then, and for every other reason, it drops the refused items from its mirror +before handing the event on, so a reconnect does not re-register them. ## Durable watch: `ResilientWatch` diff --git a/docs/manual/src/rust-sdk.md b/docs/manual/src/rust-sdk.md index e8c61527d..0c0ea2691 100644 --- a/docs/manual/src/rust-sdk.md +++ b/docs/manual/src/rust-sdk.md @@ -473,11 +473,15 @@ A watch add the server refuses is not an error. It arrives on `next()` as `Event::WatchAddRejected`, which names the kind, the reason (`QuotaExceeded`, `RateLimited`, `CapExceeded`, `PermissionDenied` or `Malformed`), the numbers behind it and the refused items; none of those items is watched. An add that -registers produces no event. `ResilientWatch` drops the refused items from its -mirror before handing the event on, so a reconnect does not re-register them -(a refused descriptor slide falls back to the window the server kept). Re-add -them yourself if you want another try, for example after `retry_after_secs` on -a rate limit. +registers produces no event. + +`ResilientWatch` treats a `RateLimited` refusal as transient: it keeps the items +in its mirror, re-sends them after `retry_after_secs` (or the backoff delay, if +longer), and absorbs the event. Only when an item has used up the backoff +budget (`Backoff::max_retries`) is the event handed on. Then, and for every +other reason, the refused items leave the mirror before the event reaches you, +so a reconnect does not re-register them (a refused descriptor slide falls back +to the window the server kept). Re-add them yourself if you want another try. ## Stability & versioning diff --git a/satd-events-client/src/event.rs b/satd-events-client/src/event.rs index 7ac10970b..0759ec8a6 100644 --- a/satd-events-client/src/event.rs +++ b/satd-events-client/src/event.rs @@ -849,10 +849,12 @@ pub enum Event { /// (already watched) are not named and stay watched. The server sends /// nothing for an add that registered. /// - /// [`ResilientWatch`](crate::ResilientWatch) drops the named items from its - /// mirror before handing this on, so a reconnect does not re-register - /// them; re-add them yourself if you want another try (for example after - /// [`retry_after_secs`](WatchAddRejected::retry_after_secs) on a rate limit). + /// [`ResilientWatch`](crate::ResilientWatch) re-sends a + /// [`RateLimited`](WatchAddRejectReason::RateLimited) add itself after the + /// server's retry hint, within its backoff budget, and hands the event on + /// only once that budget runs out. For any other reason, or then, it drops + /// the named items from its mirror before handing this on, so a reconnect + /// does not re-register them; re-add them yourself if you want another try. WatchAddRejected(WatchAddRejected), /// A body this client build does not recognize (a newer server arm), or an /// event with no body set. Ignored by well-behaved consumers. diff --git a/satd-events-client/src/resilient_watch.rs b/satd-events-client/src/resilient_watch.rs index 92ce52734..f0f260676 100644 --- a/satd-events-client/src/resilient_watch.rs +++ b/satd-events-client/src/resilient_watch.rs @@ -34,13 +34,16 @@ //! caller can escalate to a full resnapshot — the exception, not the rule. //! - **Cursor persistence** — confirmed cursors are committed-on-poll to a //! shared [`CursorStore`], so a resume survives reconnects and restarts. -//! - **Refused adds leave the mirror** — when the server answers an add with -//! [`Event::WatchAddRejected`], the items it names are dropped from the mirror -//! (a refused descriptor slide falls back to the window it replaced), so a -//! reconnect re-registers only what the server actually holds. The event is -//! still handed to the caller. - -use std::collections::{BTreeMap, BTreeSet}; +//! - **Refused adds** — when the server answers an add with +//! [`Event::WatchAddRejected`] for its rate limit, the items stay in the mirror +//! and are re-sent after the server's retry hint, within the backoff budget; +//! the event is absorbed until the budget runs out. Any other refusal (quota, +//! cap, permission, malformed), or a rate limit past the budget, drops the +//! items from the mirror (a refused descriptor slide falls back to the window +//! it replaced) and hands the event to the caller, so a reconnect re-registers +//! only what the server holds. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; @@ -52,7 +55,7 @@ use crate::client::{ validate_prefix, AutoClose, EventStream, SilentPaymentTarget, StreamClient, WatchHandle, }; use crate::error::StreamError; -use crate::event::{Cursor, CursorRejectReason, Event, WatchAddRejected}; +use crate::event::{Cursor, CursorRejectReason, Event, WatchAddRejectReason, WatchAddRejected}; use crate::resilience::{Backoff, CursorStore, NoopCursorStore}; /// A boxed integrator error returned by a watch-set loader. @@ -173,6 +176,53 @@ impl WatchSetMirror { self.descriptor_prev.remove(descriptor); } + /// The part of the mirror a refusal names, for re-sending it: only items the + /// caller still holds (a removal since the refusal wins), with their current + /// metadata (floors, auto-close, labels). A descriptor is included only at + /// the window the refusal named; a later slide already replaced it. + fn subset_for(&self, r: &WatchAddRejected) -> WatchSetMirror { + let fixed = |b: &Vec| <[u8; 32]>::try_from(b.as_slice()).ok(); + let mut out = WatchSetMirror::default(); + for sh in r.scripthashes.iter().filter_map(fixed) { + if let Some(floor) = self.scripts.get(&sh) { + out.scripts.insert(sh, *floor); + } + } + for (t, v) in &r.outpoints { + if let Some(op) = fixed(t).map(|t| (t, *v)).filter(|op| self.outpoints.contains(op)) { + out.outpoints.insert(op); + } + } + for t in r.txids.iter().filter_map(fixed) { + if let Some(close) = self.tx_lifecycles.get(&t) { + out.tx_lifecycles.insert(t, *close); + } + } + for (t, d) in &r.depth_alarms { + if let Some(alarm) = fixed(t).map(|t| (t, *d)).filter(|a| self.depth_alarms.contains(a)) { + out.depth_alarms.insert(alarm); + } + } + for (prefix, bits) in &r.prefixes { + for (b, p) in &self.prefixes { + if b == bits && mask_prefix(p, *b) == *prefix { + out.prefixes.insert((*b, p.clone())); + } + } + } + for id in r.scan_pubkeys.iter().filter_map(|b| <[u8; 33]>::try_from(b.as_slice()).ok()) { + if let Some(t) = self.silent_payments.get(&id) { + out.silent_payments.insert(id, t.clone()); + } + } + if let Some(d) = &r.descriptor + && self.descriptors.get(&d.descriptor) == Some(&(d.gap_limit, d.start)) + { + out.descriptors.insert(d.descriptor.clone(), (d.gap_limit, d.start)); + } + out + } + /// Drop what the server refused to register, so the mirror holds only what /// the server holds. Items are named exactly as the server echoes them. fn forget_rejected(&mut self, r: &WatchAddRejected) { @@ -979,6 +1029,37 @@ struct PendingReanchor { deadline: tokio::time::Instant, } +/// An add the server refused for its rate limit, waiting to be re-sent from the +/// mirror (see [`ResilientWatch::handle_event`]). Kept in `self` so a cancelled +/// [`next`](ResilientWatch::next) resumes it. +struct PendingAddRetry { + /// When the retry is due: the server's `retry_after_secs` or the backoff + /// delay for its attempt, whichever is later. + deadline: tokio::time::Instant, + /// The refusal, naming what to re-send. + rejected: WatchAddRejected, +} + +/// One identity per item a refusal names, for the per-item retry budget. The +/// first byte is the kind, so items of different kinds never collide. +fn retry_keys(r: &WatchAddRejected) -> Vec> { + let tagged = |tag: u8, bytes: &[u8]| { + let mut k = Vec::with_capacity(1 + bytes.len()); + k.push(tag); + k.extend_from_slice(bytes); + k + }; + let mut keys = Vec::new(); + keys.extend(r.scripthashes.iter().map(|b| tagged(1, b))); + keys.extend(r.outpoints.iter().map(|(t, v)| tagged(2, &[t.as_slice(), &v.to_be_bytes()].concat()))); + keys.extend(r.txids.iter().map(|b| tagged(3, b))); + keys.extend(r.depth_alarms.iter().map(|(t, d)| tagged(4, &[t.as_slice(), &d.to_be_bytes()].concat()))); + keys.extend(r.descriptor.iter().map(|d| tagged(5, d.descriptor.as_bytes()))); + keys.extend(r.prefixes.iter().map(|(p, bits)| tagged(6, &[p.as_slice(), &bits.to_be_bytes()].concat()))); + keys.extend(r.scan_pubkeys.iter().map(|b| tagged(7, b))); + keys +} + /// A `Watch` stream that reconnects, re-registers its watch-set, and re-anchors /// off the deterministic [`Event::CursorAccepted`] / [`Event::CursorRejected`] /// results on the caller's behalf. @@ -1023,6 +1104,11 @@ pub struct ResilientWatch { /// and completing it; keeping it in `self` rather than on `next`'s stack is /// what lets a cancelled `next` resume the retry (see [`PendingReanchor`]). pending_reanchor: Option, + /// Rate-limited adds awaiting their re-send, driven by [`next`](Self::next). + pending_add_retries: Vec, + /// Retries spent per refused item ([`retry_keys`]), against the backoff + /// budget. Cleared on reconnect, which re-sends the whole mirror anyway. + add_retry_attempts: HashMap, u32>, /// The most recent retryable error, surfaced if `max_retries` is exhausted. last_error: Option, } @@ -1043,6 +1129,8 @@ impl ResilientWatch { reconnect_attempts: 0, reanchor_attempts: 0, pending_reanchor: None, + pending_add_retries: Vec::new(), + add_retry_attempts: HashMap::new(), last_error: None, } } @@ -1593,8 +1681,25 @@ impl ResilientWatch { } } + // A rate-limited add whose retry is due goes out before the next read. + let now = tokio::time::Instant::now(); + if let Some(i) = self.pending_add_retries.iter().position(|p| p.deadline <= now) { + self.send_add_retry(i).await?; + continue; + } + let next_retry = self.pending_add_retries.iter().map(|p| p.deadline).min(); let stream = self.stream.as_mut().expect("connected"); - match stream.message().await { + // Race the read against the earliest retry so a pending retry does not + // hold up live events. `message` is cancel-safe: a read the timer + // preempts loses nothing. + let msg = match next_retry { + Some(deadline) => tokio::select! { + m = stream.message() => m, + _ = tokio::time::sleep_until(deadline) => continue, + }, + None => stream.message().await, + }; + match msg { Ok(Some(ev)) => { self.reconnect_attempts = 0; self.last_error = None; @@ -1634,6 +1739,37 @@ impl ResilientWatch { self.resume = Some(c); } + // A rate-limited add is transient: keep its items in the mirror and + // re-send them once the limit allows, absorbing the event, until an item + // has spent the backoff budget. Then it is handled like any other + // refusal below: dropped from the mirror and surfaced. + if let Event::WatchAddRejected(r) = &ev + && r.reason == WatchAddRejectReason::RateLimited + { + let keys = retry_keys(r); + let attempt = + keys.iter().filter_map(|k| self.add_retry_attempts.get(k)).copied().max().unwrap_or(0); + let exhausted = self.config.backoff.max_retries.is_some_and(|max| attempt >= max); + if !exhausted { + for k in keys { + self.add_retry_attempts.insert(k, attempt.saturating_add(1)); + } + let delay = self + .config + .backoff + .delay_for(attempt) + .max(std::time::Duration::from_secs(r.retry_after_secs.into())); + self.pending_add_retries.push(PendingAddRetry { + deadline: tokio::time::Instant::now() + delay, + rejected: r.clone(), + }); + return Ok(None); + } + for k in keys { + self.add_retry_attempts.remove(&k); + } + } + // One-shot watches the server auto-evicts when their terminal event // fires: prune the mirror to match, so a reconnect does not re-register // an already-fired watch (which would duplicate the terminal @@ -1802,6 +1938,10 @@ impl ResilientWatch { fn teardown(&mut self) { self.handle = None; self.stream = None; + // The reconnect re-sends the whole mirror, rate-limited items included, + // so their pending retries and budgets start over. + self.pending_add_retries.clear(); + self.add_retry_attempts.clear(); // A deferred re-anchor retry is meaningless once the stream it targeted is // gone — the reconnect re-anchors from `resume` itself. Drop it so `next` // does not sleep out a stale backoff against a dead handle before @@ -1809,6 +1949,28 @@ impl ResilientWatch { self.pending_reanchor = None; } + /// Re-send the items of the `i`th pending rate-limited add that the mirror + /// still holds. The entry is removed only after the sends resolve, so a + /// cancelled `next` re-sends it; a duplicate add is an idempotent re-assert. + async fn send_add_retry(&mut self, i: usize) -> Result<(), StreamError> { + let msgs = self.mirror.subset_for(&self.pending_add_retries[i].rejected).control_messages(); + let res = match &self.handle { + Some(h) => { + let mut res = Ok(()); + for msg in msgs { + res = h.send_control(pb::SubscribeControl { msg: Some(msg) }).await; + if res.is_err() { + break; + } + } + Some(res) + } + None => None, + }; + self.pending_add_retries.remove(i); + self.after_send(res) + } + /// Resolve a live control-send result: a `ControlClosed` means the stream /// died, so drop it (the edit is safe in the mirror and replays on /// reconnect); any other error propagates; success and the disconnected @@ -2504,7 +2666,7 @@ mod tests { w.handle_event( refused(pb::WatchAddRejected { kind: Kind::Outpoints as i32, - reason: Reason::RateLimited as i32, + reason: Reason::PermissionDenied as i32, outpoints: vec![pb::Outpoint { txid: vec![3u8; 32], vout: 1 }], ..Default::default() }), @@ -2577,7 +2739,7 @@ mod tests { w.handle_event( refused(pb::WatchAddRejected { kind: Kind::Descriptor as i32, - reason: Reason::RateLimited as i32, + reason: Reason::QuotaExceeded as i32, descriptor: "wpkh(a)".into(), gap_limit: 20, start: 20, @@ -2591,6 +2753,96 @@ mod tests { assert_eq!(w.mirror.descriptors.get("wpkh(a)"), Some(&(20, 40))); } + fn zero_backoff(max_retries: Option) -> Backoff { + Backoff { + initial: std::time::Duration::ZERO, + max: std::time::Duration::ZERO, + multiplier: 1.0, + max_retries, + } + } + + fn rate_limited_scripts(shs: &[u8]) -> Event { + use pb::watch_add_rejected::{Kind, Reason}; + refused(pb::WatchAddRejected { + kind: Kind::Scripts as i32, + reason: Reason::RateLimited as i32, + retry_after_secs: 0, + scripthashes: shs.iter().map(|b| vec![*b; 32]).collect(), + ..Default::default() + }) + } + + fn sent_scripts(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(ctrl) = rx.try_recv() { + if let Some(Msg::AddScripts(a)) = ctrl.msg { + out.push(a); + } + } + out + } + + #[tokio::test] + async fn rate_limited_add_is_re_sent_from_the_mirror() { + let store = Arc::new(MemStore::default()); + let (handle, mut rx) = crate::client::WatchHandle::for_test(); + let mut w = ResilientWatch::new( + StreamClient::for_test(), + ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(3))), + ); + w.handle = Some(handle); + w.add_scripts([([1u8; 32], None), ([2u8; 32], Some(500)), ([3u8; 32], None)]).await.unwrap(); + let _ = sent_scripts(&mut rx); + + // The node throttled the add of 2 and 3: the event is absorbed, and both + // stay in the mirror for the retry. + let out = w.handle_event(rate_limited_scripts(&[2, 3]), None).await.unwrap(); + assert!(out.is_none(), "a rate-limited add is retried, not surfaced"); + assert!(w.mirror.scripts.contains_key(&[2u8; 32]) && w.mirror.scripts.contains_key(&[3u8; 32])); + assert_eq!(w.pending_add_retries.len(), 1); + + // The caller removes 3 before the retry is due: the removal wins. + w.remove_scripts([[3u8; 32]]).await.unwrap(); + while rx.try_recv().is_ok() {} + w.send_add_retry(0).await.unwrap(); + let sent = sent_scripts(&mut rx); + assert_eq!(sent.len(), 1, "one re-send: {sent:?}"); + assert_eq!(sent[0].scripthashes, vec![vec![2u8; 32]], "only what the caller still holds"); + assert_eq!(sent[0].min_values, vec![500], "with its current floor"); + assert!(w.pending_add_retries.is_empty()); + } + + #[tokio::test] + async fn rate_limited_add_is_dropped_and_surfaced_once_the_budget_runs_out() { + let store = Arc::new(MemStore::default()); + let mut w = ResilientWatch::new( + StreamClient::for_test(), + ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(1))), + ); + w.add_scripts([([2u8; 32], None)]).await.unwrap(); + assert!(w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap().is_none(), "retry 1"); + // The retried add is throttled again: the budget of 1 is spent. + let out = w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap(); + assert!(matches!(out, Some(Event::WatchAddRejected(_))), "surfaced once the budget is spent"); + assert!(!w.mirror.scripts.contains_key(&[2u8; 32]), "and dropped from the mirror"); + assert!(w.add_retry_attempts.is_empty(), "its budget is released"); + } + + #[tokio::test] + async fn a_reconnect_resets_pending_add_retries() { + let store = Arc::new(MemStore::default()); + let mut w = ResilientWatch::new( + StreamClient::for_test(), + ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(3))), + ); + w.add_scripts([([2u8; 32], None)]).await.unwrap(); + w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap(); + w.teardown(); + assert!(w.pending_add_retries.is_empty() && w.add_retry_attempts.is_empty()); + assert!(w.mirror.scripts.contains_key(&[2u8; 32]), "the reconnect re-sends it with the mirror"); + } + // --- cursor persistence (commit-on-poll) ---------------------------------- #[tokio::test] From 570bb28942e365da96b614d933265c79432fd833 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 15:50:35 -0600 Subject: [PATCH 6/9] e2e: ResilientWatch re-sends a watch add the node throttled Co-Authored-By: Claude Opus 5.5 (1M context) --- satd/tests/e2e/sdk.rs | 60 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/satd/tests/e2e/sdk.rs b/satd/tests/e2e/sdk.rs index 945bacc63..e8417d749 100644 --- a/satd/tests/e2e/sdk.rs +++ b/satd/tests/e2e/sdk.rs @@ -12,13 +12,14 @@ use std::sync::Arc; use std::time::Duration; use satd_events_client::{ - Categories, Cursor, Event, FileCursorStore, PrefixWatcher, ResilientConfig, StatusKind, - StatusSeverity, StatusState, StreamClient, StreamError, SubscribeOptions, + Categories, Cursor, Event, FileCursorStore, PrefixWatcher, ResilientConfig, + ResilientWatchConfig, StatusKind, StatusSeverity, StatusState, StreamClient, StreamError, + SubscribeOptions, }; use crate::common::{ block1_coinbase_txid, build_signed_p2wpkh_spend_seq, display_to_internal_hex, e2e_test_timeout, - DeterministicWallet, StreamingNode, + write_authfile, DeterministicWallet, StreamingNode, TokenSpec, }; const WALLET_SEED: u8 = 0x11; @@ -213,6 +214,59 @@ async fn sdk_watch_outpoint_spent_mempool_then_confirmed() { assert!(confirmed, "second match is confirmed"); } +/// A watch add the node throttles for its rate limit is re-sent by +/// `ResilientWatch` once the limit allows: the payment to that script is still +/// seen, and the refusal never reaches the caller. With `rate_limit = "1/s"`, +/// opening the Watch spends the only token, so the replayed `AddScripts` is +/// throttled. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sdk_resilient_watch_re_sends_a_rate_limited_add() { + let fixture = write_authfile(&[TokenSpec { + id: "rl", + token: "tok-rate-limited", + capabilities: &["stream:subscribe", "stream:watch"], + rate_limit: Some("1/s"), + watch_quota: None, + }]); + let autharg: &'static str = + Box::leak(format!("--authfile={}", fixture.authfile.display()).into_boxed_str()); + let (sn, wallet) = matured_node_args(vec![autharg, "--events-grpc-auth=1"]).await; + let dest = DeterministicWallet::from_secret([0x66; 32]).address.script_pubkey(); + let scripthash: [u8; 32] = { + use bitcoin::hashes::{sha256, Hash}; + sha256::Hash::hash(dest.as_bytes()).to_byte_array() + }; + + let client = StreamClient::builder(format!("http://127.0.0.1:{}", sn.grpc_port())) + .insecure_bearer_token("tok-rate-limited") + .connect() + .await + .expect("connect with the token"); + let mut watch = client.resilient_watch(ResilientWatchConfig::new()); + watch.add_scripts([(scripthash, None)]).await.expect("add_scripts"); + + // Pull events in the background until the match arrives; anything else that + // reaches the caller first must not be the refusal. + let matched = tokio::spawn(async move { + loop { + match watch.next().await.expect("no stream error") { + Event::ScriptMatched { scripthash: sh, .. } => return sh, + Event::WatchAddRejected(r) => panic!("a rate-limited add reached the caller: {r:?}"), + _ => {} + } + } + }); + // Past the 1 s retry hint, so the re-sent add has landed. + tokio::time::sleep(Duration::from_millis(2_500)).await; + let (_spend_txid, paid) = broadcast_spend(&sn, &wallet, 0x66, 10_000).await; + assert_eq!(paid, dest); + let sh = tokio::time::timeout(e2e_test_timeout(20), matched) + .await + .expect("the throttled script matched within the timeout") + .expect("watch task"); + assert_eq!(sh, scripthash.to_vec(), "the re-sent watch delivered the payment"); +} + /// A privacy-preserving prefix watch: register a coarse bucket, receive the /// decoy-laden delivery, and re-filter it locally to the true funding match /// with `PrefixWatcher`. From 7458ce67df5c659e079d32388757c14d98add306 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 17:23:28 -0600 Subject: [PATCH 7/9] SP label re-asserts are free; a refused slide falls back past every refusal Review round 1: - A silent-payment add that only re-asserted held targets (a label update) spent a rate token and could come back RATE_LIMITED naming no items. Re-asserted targets are now free and always applied, like a re-asserted script's floor in add_items_priced; only net-new targets are charged, and a refusal names only them. Their label updates apply even when the net-new targets are refused. - ResilientWatch kept one previous window per descriptor, so two refused slides in flight restored the first refused window. Both SDKs now keep a short history of earlier windows: a refused window leaves it, and a refused latest window falls back to the latest earlier one that was not refused. Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/go/mirror.go | 41 ++++++++---- clients/go/mirror_test.go | 20 ++++++ clients/go/watchset.go | 30 +++++++-- docs/api/streaming.md | 3 +- events/src/watchset.rs | 63 ++++++++++++++++-- satd-events-client/src/resilient_watch.rs | 64 ++++++++++++++++--- .../proto/satd/events/v1/events.proto | 4 +- 7 files changed, 189 insertions(+), 36 deletions(-) diff --git a/clients/go/mirror.go b/clients/go/mirror.go index 4c428e2b0..bc3c08f4b 100644 --- a/clients/go/mirror.go +++ b/clients/go/mirror.go @@ -44,7 +44,7 @@ func (w *WatchSet) removeDepthAlarms(txids [][32]byte, depths []uint32) { func (w *WatchSet) removeDescriptor(descriptor string) { delete(w.descriptors, descriptor) - delete(w.descriptorPrev, descriptor) + delete(w.descriptorHistory, descriptor) } // subsetFor is the part of the mirror a refusal names, for re-sending it: only @@ -195,19 +195,38 @@ func (w *WatchSet) forgetRejected(r *WatchAddRejected) { } } if d := r.Descriptor; d != nil { - // Only the window the refusal names; a later slide already replaced it. - if cur, ok := w.descriptors[d.Descriptor]; ok && cur == (descriptorWindow{gapLimit: d.GapLimit, start: d.Start}) { - prev, hadPrev := w.descriptorPrev[d.Descriptor] - delete(w.descriptorPrev, d.Descriptor) + refused := descriptorWindow{gapLimit: d.GapLimit, start: d.Start} + history := w.descriptorHistory[d.Descriptor] + if cur, ok := w.descriptors[d.Descriptor]; ok && cur == refused { + // The node refused the latest window. It handles slides in order, + // so what it keeps is the latest earlier window that was not itself + // refused. switch { - case d.Kept && hadPrev: - w.descriptors[d.Descriptor] = prev + case d.Kept && len(history) > 0: + w.descriptors[d.Descriptor] = history[len(history)-1] + history = history[:len(history)-1] case d.Kept: // Kept, but the mirror never saw the earlier window (a // loader-built set): leave the requested one to replay. default: delete(w.descriptors, d.Descriptor) + history = nil } + } else { + // An earlier slide was refused while a later one is pending: that + // window cannot be the one the node keeps. + kept := history[:0:0] + for _, h := range history { + if h != refused { + kept = append(kept, h) + } + } + history = kept + } + if len(history) == 0 { + delete(w.descriptorHistory, d.Descriptor) + } else { + w.descriptorHistory[d.Descriptor] = history } } } @@ -263,10 +282,10 @@ func (w *WatchSet) clone() *WatchSet { out.descriptors[k] = v } } - if len(w.descriptorPrev) > 0 { - out.descriptorPrev = make(map[string]descriptorWindow, len(w.descriptorPrev)) - for k, v := range w.descriptorPrev { - out.descriptorPrev[k] = v + if len(w.descriptorHistory) > 0 { + out.descriptorHistory = make(map[string][]descriptorWindow, len(w.descriptorHistory)) + for k, v := range w.descriptorHistory { + out.descriptorHistory[k] = append([]descriptorWindow(nil), v...) } } if len(w.prefixes) > 0 { diff --git a/clients/go/mirror_test.go b/clients/go/mirror_test.go index d1c18efc8..9401c1119 100644 --- a/clients/go/mirror_test.go +++ b/clients/go/mirror_test.go @@ -352,3 +352,23 @@ func generatorPubkey() [33]byte { copy(out[:], raw) return out } + +// TestRefusedDescriptorSlidesFallBackPastEveryRefusal: with two slides in +// flight and both refused, the window to replay is the one held before either, +// not the first refused one. +func TestRefusedDescriptorSlidesFallBackPastEveryRefusal(t *testing.T) { + m := NewWatchSet().AddDescriptor("wpkh(c)", 20, 0).AddDescriptor("wpkh(c)", 20, 20).AddDescriptor("wpkh(c)", 20, 40) + for _, start := range []uint32{20, 40} { + m.forgetRejected(&WatchAddRejected{ + Kind: WatchAddKindDescriptor, + Reason: WatchAddRejectQuotaExceeded, + Descriptor: &RejectedDescriptor{Descriptor: "wpkh(c)", GapLimit: 20, Start: start, Kept: true}, + }) + } + if got := m.descriptors["wpkh(c)"]; got != (descriptorWindow{gapLimit: 20, start: 0}) { + t.Fatalf("window = %+v, want the one held before both refused slides (start 0)", got) + } + if len(m.descriptorHistory) != 0 { + t.Errorf("history not cleared: %v", m.descriptorHistory) + } +} diff --git a/clients/go/watchset.go b/clients/go/watchset.go index 26800f9dd..754c3880c 100644 --- a/clients/go/watchset.go +++ b/clients/go/watchset.go @@ -39,9 +39,10 @@ type WatchSet struct { depthAlarms map[depthAlarm]struct{} // descriptors maps a descriptor string to its latest window. descriptors map[string]descriptorWindow - // descriptorPrev maps a descriptor to the window its latest add replaced, - // so a slide the node refuses can fall back to the window the node holds. - descriptorPrev map[string]descriptorWindow + // descriptorHistory maps a descriptor to the windows its earlier adds asked + // for, oldest first, so a slide the node refuses can fall back to the + // window the node still holds. Bounded by descriptorHistoryLen. + descriptorHistory map[string][]descriptorWindow // prefixes is the set of registered buckets, keyed by (bits, prefix hex) so // the byte slice does not have to be a map key. prefixes map[prefixKey]ScriptPrefix @@ -53,6 +54,11 @@ type WatchSet struct { includeRawTx *bool } +// descriptorHistoryLen bounds the earlier windows kept per descriptor for a +// refused slide to fall back to. A caller slides one step at a time and the +// node answers in order, so a few are plenty; the oldest is dropped past this. +const descriptorHistoryLen = 8 + type depthAlarm struct { txid [32]byte depth uint32 @@ -139,12 +145,22 @@ func (w *WatchSet) AddDescriptor(descriptor string, gapLimit, start uint32) *Wat w.descriptors[descriptor] = win switch { case had && prev != win: - if w.descriptorPrev == nil { - w.descriptorPrev = map[string]descriptorWindow{} + if w.descriptorHistory == nil { + w.descriptorHistory = map[string][]descriptorWindow{} + } + history := w.descriptorHistory[descriptor][:0:0] + for _, h := range w.descriptorHistory[descriptor] { + if h != win { + history = append(history, h) + } + } + history = append(history, prev) + if len(history) > descriptorHistoryLen { + history = history[1:] } - w.descriptorPrev[descriptor] = prev + w.descriptorHistory[descriptor] = history case !had: - delete(w.descriptorPrev, descriptor) + delete(w.descriptorHistory, descriptor) } return w } diff --git a/docs/api/streaming.md b/docs/api/streaming.md index a2d1ad4da..76b08804e 100644 --- a/docs/api/streaming.md +++ b/docs/api/streaming.md @@ -517,8 +517,7 @@ no event, so a client that sees none can rely on its adds having landed. The add is all-or-nothing over its **net-new** items: none of the items the event names is watched. Items the message re-asserted (already watched) are not named and stay watched; their metadata (a script's `min_value` floor, a silent-payment target's -labels) still updates, except when a silent-payment add is refused for its cap or -the rate limit. +labels) still updates, and a message that only re-asserts is never refused. ```proto message WatchAddRejected { diff --git a/events/src/watchset.rs b/events/src/watchset.rs index 87a62e858..1fe697dbd 100644 --- a/events/src/watchset.rs +++ b/events/src/watchset.rs @@ -1078,7 +1078,9 @@ impl WatchSet { /// push the retained count over the cap, the whole add is shed (like a /// descriptor over its cap). All-or-nothing on quota. `register` receives the /// net-new targets AND the re-asserted ones so the caller applies label - /// updates in the matcher. A rejection names the net-new targets by identity. + /// updates in the matcher. A re-assert is free (no rate token) and its label + /// update applies even when the add's net-new targets are refused; a + /// rejection names only the net-new targets, by identity. pub(crate) fn add_silent_payments( &mut self, principal: Option<&satd_auth::Principal>, @@ -1104,6 +1106,14 @@ impl WatchSet { if net_new.is_empty() && reassert.is_empty() { return Ok(()); } + // A re-asserted target is already inside every limit, so its label update + // is free and always applies, like a re-asserted script's floor in + // `add_items_priced`. Only net-new targets are charged, and only they can + // be refused. + if net_new.is_empty() { + register(&reassert); + return Ok(()); + } let rejected = |reason, net_new: &[node::events::SpWatchTarget]| AddRejected { reason, items: RejectedItems::SilentPayments(net_new.iter().map(|t| t.scan_pubkey()).collect()), @@ -1121,6 +1131,9 @@ impl WatchSet { requested: (self.silent_payments.len() + net_new.len()) as u64, limit: MAX_SP_TARGETS_PER_CONNECTION as u64, }; + if !reassert.is_empty() { + register(&reassert); + } return Err(rejected(reason, &net_new)); } if let Err(reason) = self.room().check(net_new.len()) { @@ -1129,11 +1142,14 @@ impl WatchSet { kind = "silent_payments", "watch-set at per-connection entry cap; skipping add", ); + if !reassert.is_empty() { + register(&reassert); + } return Err(rejected(reason, &net_new)); } - // Per-add rate limit (mirrors `add_items_priced`): one token per - // effective add/update, after the empty short-circuit so a fully-empty - // message cannot burn the bucket. + // Per-add rate limit (mirrors `add_items_priced`): one token per add + // with net-new targets, after the short-circuits above so a re-assert + // or an empty message cannot burn the bucket. if let Some(p) = principal && let satd_auth::RateDecision::Throttle { retry_after_secs } = p.check_rate() { @@ -1143,6 +1159,9 @@ impl WatchSet { retry_after_secs, "watch add rate-limited; skipping", ); + if !reassert.is_empty() { + register(&reassert); + } return Err(rejected(AddRejectReason::RateLimited { retry_after_secs }, &net_new)); } let new_ids: Vec<[u8; 33]> = net_new.iter().map(|t| t.scan_pubkey()).collect(); @@ -2577,6 +2596,42 @@ mod tests { assert_eq!(ws.len(), 1); } + #[test] + fn sp_reassert_is_free_and_applies_when_new_targets_are_refused() { + use satd_auth::RatePolicy; + let acct: Arc = Arc::new(LocalAccounting::new()); + let p = Principal::token( + Arc::from("tenant"), + CapabilitySet::EMPTY.with(Capability::StreamWatch), + Some(100), + Some(RatePolicy { burst: 1, per_sec: 1 }), + acct, + ); + let mut ws = WatchSet::default(); + ws.add_silent_payments(Some(&p), vec![sp_target(1)], |_| {}).unwrap(); // the only token + + // A label-only re-assert needs no token: it applies and nothing is refused. + let mut applied = Vec::new(); + ws.add_silent_payments(Some(&p), vec![sp_target(1)], |ts| { + applied = ts.iter().map(|t| t.scan_pubkey()).collect(); + }) + .unwrap(); + assert_eq!(applied, vec![sp_target(1).scan_pubkey()]); + + // A new target with the bucket empty is refused; the re-assert in the + // same message still applies, and only the new target is named. + let mut applied = Vec::new(); + let rejected = ws + .add_silent_payments(Some(&p), vec![sp_target(1), sp_target(2)], |ts| { + applied = ts.iter().map(|t| t.scan_pubkey()).collect(); + }) + .unwrap_err(); + assert!(matches!(rejected.reason, AddRejectReason::RateLimited { .. }), "{rejected:?}"); + assert_eq!(rejected.items, RejectedItems::SilentPayments(vec![sp_target(2).scan_pubkey()])); + assert_eq!(applied, vec![sp_target(1).scan_pubkey()], "the re-assert's labels still apply"); + assert_eq!(ws.len(), 1); + } + #[test] fn sp_reassert_reregisters_for_label_updates() { // A held scan key re-added (e.g. a wallet that starts catching its own diff --git a/satd-events-client/src/resilient_watch.rs b/satd-events-client/src/resilient_watch.rs index f0f260676..0bf721d8d 100644 --- a/satd-events-client/src/resilient_watch.rs +++ b/satd-events-client/src/resilient_watch.rs @@ -95,9 +95,10 @@ pub(crate) struct WatchSetMirror { depth_alarms: BTreeSet<(Txid, u32)>, /// Descriptor → its latest `(gap_limit, start)` window. descriptors: BTreeMap, - /// Descriptor → the window its latest add replaced, so a slide the server - /// refuses can fall back to the window the server still holds. - descriptor_prev: BTreeMap, + /// Descriptor → the windows its earlier adds asked for, oldest first, so a + /// slide the server refuses can fall back to the window it still holds. + /// Bounded by [`DESCRIPTOR_HISTORY`]. + descriptor_history: BTreeMap>, /// Script-prefix buckets, as `(bits, prefix)` (validated on insert). prefixes: BTreeSet<(u32, Vec)>, /// BIP 352 scan-key targets, keyed by identity `b_scan·G` (33 bytes) — the @@ -162,18 +163,23 @@ impl WatchSetMirror { let window = (gap_limit, start); match self.descriptors.insert(descriptor.clone(), window) { Some(prev) if prev != window => { - self.descriptor_prev.insert(descriptor, prev); + let history = self.descriptor_history.entry(descriptor).or_default(); + history.retain(|w| *w != window); + history.push(prev); + if history.len() > DESCRIPTOR_HISTORY { + history.remove(0); + } } Some(_) => {} None => { - self.descriptor_prev.remove(&descriptor); + self.descriptor_history.remove(&descriptor); } } } fn remove_descriptor(&mut self, descriptor: &str) { self.descriptors.remove(descriptor); - self.descriptor_prev.remove(descriptor); + self.descriptor_history.remove(descriptor); } /// The part of the mirror a refusal names, for re-sending it: only items the @@ -246,9 +252,13 @@ impl WatchSetMirror { r.scan_pubkeys.iter().filter_map(|b| <[u8; 33]>::try_from(b.as_slice()).ok()).collect(); self.remove_silent_payments(&ids); if let Some(d) = &r.descriptor { - // Only the window the refusal names; a later slide already replaced it. - if self.descriptors.get(&d.descriptor) == Some(&(d.gap_limit, d.start)) { - match self.descriptor_prev.remove(&d.descriptor) { + let refused = (d.gap_limit, d.start); + if self.descriptors.get(&d.descriptor) == Some(&refused) { + // The node refused the latest window. It handles slides in + // order, so what it keeps is the latest earlier window that was + // not itself refused. + let prev = self.descriptor_history.get_mut(&d.descriptor).and_then(Vec::pop); + match prev { Some(prev) if d.kept => { self.descriptors.insert(d.descriptor.clone(), prev); } @@ -257,8 +267,16 @@ impl WatchSetMirror { None if d.kept => {} _ => { self.descriptors.remove(&d.descriptor); + self.descriptor_history.remove(&d.descriptor); } } + } else if let Some(history) = self.descriptor_history.get_mut(&d.descriptor) { + // An earlier slide was refused while a later one is pending: that + // window cannot be the one the node keeps. + history.retain(|w| *w != refused); + } + if self.descriptor_history.get(&d.descriptor).is_some_and(Vec::is_empty) { + self.descriptor_history.remove(&d.descriptor); } } } @@ -2011,6 +2029,11 @@ impl ResilientWatch { } } +/// Earlier windows kept per descriptor for a refused slide to fall back to. A +/// caller slides one step at a time and the server answers in order, so a few +/// are plenty; the oldest is dropped past this. +const DESCRIPTOR_HISTORY: usize = 8; + /// `prefix` with the bits past `bits` cleared, as the server echoes it. fn mask_prefix(prefix: &[u8], bits: u32) -> Vec { let mut out = prefix.to_vec(); @@ -2734,6 +2757,29 @@ mod tests { .unwrap(); assert!(!w.mirror.descriptors.contains_key("wpkh(b)")); + // Two slides in flight, both refused: the fallback is the window held + // before either, not the first refused one. + w.add_descriptor("wpkh(c)", 20, 0).await.unwrap(); + w.add_descriptor("wpkh(c)", 20, 20).await.unwrap(); + w.add_descriptor("wpkh(c)", 20, 40).await.unwrap(); + for start in [20, 40] { + w.handle_event( + refused(pb::WatchAddRejected { + kind: Kind::Descriptor as i32, + reason: Reason::QuotaExceeded as i32, + descriptor: "wpkh(c)".into(), + gap_limit: 20, + start, + descriptor_kept: true, + ..Default::default() + }), + None, + ) + .await + .unwrap(); + } + assert_eq!(w.mirror.descriptors.get("wpkh(c)"), Some(&(20, 0)), "falls back past both refusals"); + // A refusal for a window the caller already slid past changes nothing. w.add_descriptor("wpkh(a)", 20, 40).await.unwrap(); w.handle_event( diff --git a/satd-events-proto/proto/satd/events/v1/events.proto b/satd-events-proto/proto/satd/events/v1/events.proto index 9c7323199..fb0881f23 100644 --- a/satd-events-proto/proto/satd/events/v1/events.proto +++ b/satd-events-proto/proto/satd/events/v1/events.proto @@ -856,9 +856,7 @@ message WatchSetRejected { // The add is all-or-nothing over its NET-NEW items: none of the items echoed // here is watched. Items the message re-asserted (already watched) are not // echoed and stay watched; their metadata (a script's min_value floor, a -// silent-payment target's labels) still updates, except when the refusal is -// CAP_EXCEEDED or RATE_LIMITED on a silent-payment add, which leaves held -// targets' labels as they were. +// silent-payment target's labels) still updates. // // `required`/`quota` carry the numbers behind `reason`: // QUOTA_EXCEEDED — `required` = units the refused items cost, `held` = From c55866ddb171e472b70a7f5bcfcbffc229e08950 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 17:25:28 -0600 Subject: [PATCH 8/9] clients/go: regenerate bindings for the WatchAddRejected comment Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/go/eventspb/events.pb.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/clients/go/eventspb/events.pb.go b/clients/go/eventspb/events.pb.go index 2d3a41b7e..5592c2a06 100644 --- a/clients/go/eventspb/events.pb.go +++ b/clients/go/eventspb/events.pb.go @@ -4991,9 +4991,7 @@ func (x *WatchSetRejected) GetQuota() uint64 { // The add is all-or-nothing over its NET-NEW items: none of the items echoed // here is watched. Items the message re-asserted (already watched) are not // echoed and stay watched; their metadata (a script's min_value floor, a -// silent-payment target's labels) still updates, except when the refusal is -// CAP_EXCEEDED or RATE_LIMITED on a silent-payment add, which leaves held -// targets' labels as they were. +// silent-payment target's labels) still updates. // // `required`/`quota` carry the numbers behind `reason`: // From f9b9e209cee8a011b1e78823d40ada3c434368a5 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 6 Oct 2026 07:07:22 -0600 Subject: [PATCH 9/9] docs: open the 0.6.1 notes with the WatchAddRejected fix The fix ships in 0.6.1 from release/0.6, so its changelog bullet and write-up go into the 0.6.1 cycle rather than the 0.6.0 notes it was first written against. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 10 ++-- docs/release-notes/0.6.1-pre.md | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 docs/release-notes/0.6.1-pre.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9c38ed2..af2528271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,13 @@ layout) per [`STABILITY_POLICY.md`](STABILITY_POLICY.md). ## [Unreleased] -Nothing yet — 0.6.0 was cut on 2026-10-05. Add a one-line bullet here for every -user-facing change, and the full write-up to the next -`docs/release-notes/-pre.md`. +Bound for **0.6.1**, a patch release on the 0.6 line. This is an index: every +item below is (or will be) written up in full in the in-development +[`docs/release-notes/0.6.1-pre.md`](docs/release-notes/0.6.1-pre.md). + +### Fixed + +- A watch add the streaming server refused (over the token's watch quota, over the per-add rate limit, over a per-connection cap, without `stream:watch`, or malformed) was logged on the node and dropped, so a client could believe it was watching addresses it was not. The server now answers it on the `Watch` stream with a `WatchAddRejected` event (`watch_add_rejected` on WebSocket) naming the reason and the refused items, as the streaming docs promised; both SDKs surface it. `ResilientWatch` re-sends a rate-limited add once the limit allows, within its backoff budget, and stops re-registering other refused items on reconnect. A silent-payment add that only updates the labels of targets already watched no longer spends a rate-limit token. ## Releases diff --git a/docs/release-notes/0.6.1-pre.md b/docs/release-notes/0.6.1-pre.md new file mode 100644 index 000000000..ae452216e --- /dev/null +++ b/docs/release-notes/0.6.1-pre.md @@ -0,0 +1,87 @@ +# satd 0.6.1 + +> Pre-release — in progress · [Changelog](../../CHANGELOG.md) + +satd 0.6.1 is a patch release on the 0.6 line, cut from the `release/0.6` +branch. It carries fixes for 0.6.0. + +This file accumulates entries as changes land; it is not yet a cut release. + +## Highlights + +- **A refused watch add is reported to the client.** The streaming server used + to drop a watch add it refused without telling the client; it now answers + with a `WatchAddRejected` event. See + [below](#a-refused-watch-add-is-reported-to-the-client). + +## Streaming Consumption API + +### A refused watch add is reported to the client + +The streaming docs said an over-quota watch add was rejected with +`RESOURCE_EXHAUSTED` on gRPC or `429` on WebSocket. The server sent neither: an +incremental `Add*` it refused was logged on the node and dropped, and the client +was never told. A wallet that hit its quota could believe it was watching an +address and miss a payment to it. + +The server now answers every incremental add it does not register with one +`WatchAddRejected` event on the `Watch` stream (`watch_add_rejected` on +WebSocket). It names the kind of add, the reason, the numbers behind it and the +refused items; none of those items is watched. An add that registers still gets +no event. + +| Reason | When | +|---|---| +| `QUOTA_EXCEEDED` | the items do not fit the token's watch quota; carries the units required, held and allowed | +| `RATE_LIMITED` | the token's per-add rate limit is spent; carries a retry-after hint | +| `CAP_EXCEEDED` | a per-connection cap: 16 silent-payment targets, 256 descriptors, or `streamwsmaxsubscriptions` | +| `PERMISSION_DENIED` | the token lacks `stream:watch` | +| `MALFORMED` | the message cannot be applied as a whole, such as an invalid descriptor | + +Both SDKs decode the event (Rust `Event::WatchAddRejected`, Go +`*WatchAddRejected`). `ResilientWatch` re-sends a rate-limited add itself once the +limit allows, within its backoff budget. Any other refusal, or a rate limit past +the budget, drops the refused items from its mirror, so a reconnect no longer +re-sends adds the server refused; a refused descriptor slide falls back to the +window the server kept. + +Two adds that grow nothing are no longer refused. A silent-payment add that only +updates the labels of targets already watched is applied without spending a +per-add rate token, and on WebSocket an add that only re-asserts items already +watched is no longer shed when the connection is at `streamwsmaxsubscriptions`. + +The event is an additive schema change (`NodeEvent` tag 32): an older SDK ignores +it, and an older server never sends it. + +## Upgrade notes + +- Drop-in upgrade from 0.6.0 for the changes so far: no reindex and no + configuration change. +- A client that treated silence after a watch add as success should handle + `WatchAddRejected`; one built on a 0.6.0 SDK keeps working and ignores the + event. + +--- + +## About this release + +satd is free software under the [MIT License](../../LICENSE). + +- **Stability & compatibility** — Tier 1 surfaces (Bitcoin Core-compatible + JSON-RPC wire shape, CLI flag names/defaults, `bitcoin.conf` keys, on-disk + layout, Electrum/Esplora surfaces) are governed by + [`STABILITY_POLICY.md`](../../STABILITY_POLICY.md). satd follows + [semantic versioning](https://semver.org/spec/v2.0.0.html); while in the + `0.x` pre-1.0 line, deprecations may be accelerated (see the policy). +- **Security & disclosure** — report vulnerabilities per + [`SECURITY.md`](../../SECURITY.md). Consensus-affecting bugs are treated as + P0 with same-day acknowledgement. +- **Verifying this release** — tarballs are signed with minisign, container + images with cosign (keyless OIDC), and git tags with SSH signatures (no GPG). + The key matrix and verification steps are in + [`SECURITY.md`](../../SECURITY.md). +- **Intentional differences from Bitcoin Core** — catalogued in + [`CORE_DIFFERENCES.md`](../../CORE_DIFFERENCES.md). +- **Operating satd** — observability, the full flag matrix, tuning, and the + downstream packaging contract live in the + [Operator Manual](https://epochbtc.github.io/satd/).