Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>-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

Expand Down
46 changes: 46 additions & 0 deletions clients/go/cmd/paritydump/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions clients/go/enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions clients/go/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions clients/go/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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() {}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
18 changes: 18 additions & 0 deletions clients/go/events_exhaustive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Loading
Loading