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/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/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/clients/go/events.go b/clients/go/events.go index 5892ab205..3f6398b4d 100644 --- a/clients/go/events.go +++ b/clients/go/events.go @@ -594,6 +594,74 @@ 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] 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 + // 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 +734,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 +921,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 +1040,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/eventspb/events.pb.go b/clients/go/eventspb/events.pb.go index e6486d78f..5592c2a06 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,202 @@ 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. +// +// `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 +5197,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 +5209,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 +5222,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 +5282,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 +5294,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 +5307,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 +5344,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 +5356,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 +5369,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 +5400,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 +5412,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 +5425,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 +5483,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 +5491,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 +5503,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 +5516,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 +5736,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 +5833,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 +5879,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 +6207,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 +6270,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 +6296,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 +6343,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 +6352,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 +6568,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 +6594,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/clients/go/mirror.go b/clients/go/mirror.go index 9d35c6c84..bc3c08f4b 100644 --- a/clients/go/mirror.go +++ b/clients/go/mirror.go @@ -44,6 +44,191 @@ func (w *WatchSet) removeDepthAlarms(txids [][32]byte, depths []uint32) { func (w *WatchSet) removeDescriptor(descriptor string) { delete(w.descriptors, descriptor) + delete(w.descriptorHistory, 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. +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 { + 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 && 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 + } + } } func (w *WatchSet) removeScriptPrefixes(prefixes ...ScriptPrefix) { @@ -97,6 +282,12 @@ func (w *WatchSet) clone() *WatchSet { out.descriptors[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 { out.prefixes = make(map[prefixKey]ScriptPrefix, len(w.prefixes)) for k, v := range w.prefixes { @@ -196,9 +387,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/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/resilientwatch.go b/clients/go/resilientwatch.go index 76c6ec348..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 { @@ -750,6 +815,17 @@ 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: + // 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 { 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..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" @@ -1107,3 +1108,159 @@ 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_QUOTA_EXCEEDED, + 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) + } +} + +// 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/clients/go/watchset.go b/clients/go/watchset.go index 60176196a..754c3880c 100644 --- a/clients/go/watchset.go +++ b/clients/go/watchset.go @@ -39,6 +39,10 @@ type WatchSet struct { depthAlarms map[depthAlarm]struct{} // descriptors maps a descriptor string to its latest window. descriptors 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 @@ -50,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 @@ -131,7 +140,28 @@ 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.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.descriptorHistory[descriptor] = history + case !had: + delete(w.descriptorHistory, descriptor) + } return w } diff --git a/docs/api/streaming.md b/docs/api/streaming.md index 0b6b5592f..76b08804e 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,56 @@ 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, and a message that only re-asserts is never refused. + +```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 +1218,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 +1436,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..a5ad483f3 100644 --- a/docs/manual/src/go-sdk.md +++ b/docs/manual/src/go-sdk.md @@ -227,6 +227,16 @@ 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` 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` ```go diff --git a/docs/manual/src/rust-sdk.md b/docs/manual/src/rust-sdk.md index 9741e2bef..0c0ea2691 100644 --- a/docs/manual/src/rust-sdk.md +++ b/docs/manual/src/rust-sdk.md @@ -466,9 +466,22 @@ 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` 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/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/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/). 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..1fe697dbd 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,15 @@ 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 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>, 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 +1104,20 @@ impl WatchSet { } } if net_new.is_empty() && reassert.is_empty() { - return; + 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()), + }; // 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,11 +1127,29 @@ 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, + }; + if !reassert.is_empty() { + register(&reassert); + } + 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", + ); + 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() { @@ -1000,8 +1159,12 @@ impl WatchSet { retry_after_secs, "watch add rate-limited; skipping", ); - return; + 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(); // 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 +1176,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 +1184,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 +1201,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 +1222,7 @@ impl WatchSet { for t in to_register.iter().take(n_new) { self.silent_payments.insert(t.scan_pubkey(), None); } + Ok(()) } } } @@ -1077,32 +1250,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 +1313,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 +1332,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 +1343,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 +1366,7 @@ fn add_items_priced( ); held.insert(it, lease); } + Ok(()) } Err(reject) => { warn!( @@ -1186,6 +1375,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 +1384,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 +1421,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 +1435,7 @@ fn reserve_scripts( ); held.insert(*s, lease); } - true + Ok(()) } Err(reject) => { warn!( @@ -1248,7 +1444,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 +1453,7 @@ fn reserve_scripts( for s in net_new { held.insert(*s, None); } - true + Ok(()) } } } @@ -1318,7 +1514,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 +1533,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 +1569,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 +1583,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 +1597,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 +1619,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 +1635,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 +1724,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 +1755,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 +1763,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 +1778,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 +1791,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 +1804,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 +1826,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 +1834,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 +1880,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 +1897,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 +1914,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 +1949,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 +1973,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 +1992,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 +2001,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 +2027,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 +2051,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 +2063,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 +2078,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 +2097,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 +2116,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 +2151,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 +2237,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 +2439,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 +2469,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 +2498,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 +2512,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 +2521,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 +2529,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 +2541,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 +2553,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 +2580,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; @@ -2281,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 @@ -2291,7 +2642,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 +2650,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 +2665,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-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), diff --git a/satd-events-client/src/event.rs b/satd-events-client/src/event.rs index 3210f7aba..0759ec8a6 100644 --- a/satd-events-client/src/event.rs +++ b/satd-events-client/src/event.rs @@ -842,11 +842,168 @@ 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) 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. 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_some(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 +1201,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..0bf721d8d 100644 --- a/satd-events-client/src/resilient_watch.rs +++ b/satd-events-client/src/resilient_watch.rs @@ -34,8 +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. - -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}; @@ -47,7 +55,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, WatchAddRejectReason, WatchAddRejected}; use crate::resilience::{Backoff, CursorStore, NoopCursorStore}; /// A boxed integrator error returned by a watch-set loader. @@ -87,6 +95,10 @@ pub(crate) struct WatchSetMirror { depth_alarms: BTreeSet<(Txid, u32)>, /// Descriptor → its latest `(gap_limit, start)` window. descriptors: 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 @@ -148,11 +160,125 @@ 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 => { + 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_history.remove(&descriptor); + } + } } fn remove_descriptor(&mut self, descriptor: &str) { self.descriptors.remove(descriptor); + self.descriptor_history.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) { + 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 { + 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); + } + // 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); + 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); + } + } } fn add_prefixes(&mut self, items: &[pb::ScriptPrefix]) { @@ -921,6 +1047,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. @@ -965,6 +1122,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, } @@ -985,6 +1147,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, } } @@ -1535,8 +1699,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; @@ -1576,6 +1757,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 @@ -1584,6 +1796,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)]); @@ -1741,6 +1956,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 @@ -1748,6 +1967,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 @@ -1788,6 +2029,23 @@ 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(); + 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 +2653,242 @@ 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::PermissionDenied 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)")); + + // 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( + 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, 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] 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-events-proto/proto/satd/events/v1/events.proto b/satd-events-proto/proto/satd/events/v1/events.proto index 3abc51155..fb0881f23 100644 --- a/satd-events-proto/proto/satd/events/v1/events.proto +++ b/satd-events-proto/proto/satd/events/v1/events.proto @@ -848,6 +848,73 @@ 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. +// +// `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 +1002,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) } } 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/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`. 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 // ===========================================================================