Skip to content

feat(frost/roast): close M4 -- reject + conflict evidence categories#3988

Merged
mswilkison merged 1 commit into
feat/frost-schnorr-migration-scaffoldfrom
feat/frost-roast-reject-conflict-evidence-2026-05-23
May 23, 2026
Merged

feat(frost/roast): close M4 -- reject + conflict evidence categories#3988
mswilkison merged 1 commit into
feat/frost-schnorr-migration-scaffoldfrom
feat/frost-roast-reject-conflict-evidence-2026-05-23

Conversation

@mswilkison
Copy link
Copy Markdown

Summary

Closes the M4 gap from the original PR #3866 review by adding
the two evidence categories the RFC-21 Phase-2 work left as future
work: validation-rejection evidence and first-write-wins-conflict
evidence
.

With this PR, the `NextAttempt` policy can permanently exclude
misbehaving peers on all four ROAST blame channels --
transport-overflow, validation-reject, equivocation-conflict, and
silence -- instead of just overflow + silence.

Why this matters

A peer that only sends malformed messages (validation rejects,
never overflows the channel) was previously indistinguishable from
a silent peer. The transient silence-parking policy would
bench-and-reinstate them indefinitely, never permanently excluding
the malicious behaviour. Same for a peer equivocating mid-attempt:
the existing first-write-wins assembly correctly dropped the
conflicting retransmission but only logged the event -- the bundle
carried no structured evidence the coordinator's policy could act
on.

What lands

Recorder API

Surface Notes
`RecordReject(sender, reason)` reason captured verbatim; per-reason quota counter
`RecordConflict(sender)` saturates at conflict quota
`RejectQuotaDefault = 8`, `ConflictQuotaDefault = 4` matches RFC-21 Layer A categoryQuota
Per-reason quotas independent peer cannot saturate one reason to mask another

Wire types

Type Sort order Cap
`RejectEntry{Sender, Reason, Count}` asc by Sender, then asc by Reason per-attempt evidence size bounded by Σ quotas
`ConflictEntry{Sender, Count}` asc by Sender per-attempt evidence size bounded by Σ quotas

Both fields use `omitempty` so pre-PR snapshots round-trip without
the new fields. `Validate()` enforces sorted-ascending invariants.

NextAttempt policy

Threshold Value Source
`RejectExclusionThreshold` 1 RFC-21 Layer B ("any non-transport reject is sufficient cause")
`ConflictExclusionThreshold` 1 A single conflict is byzantine evidence

`computeNextAttempt` merges `overflowBlamed`, `rejectBlamed`,
`conflictBlamed` into the permanent ExcludedSet. The
`blamedSenders` helper is factored out so all three categories
share the deterministic sort + threshold-comparison logic.

Receive-loop wiring

Three reject sites and three conflict sites updated across the two
files that house the three FROST/tbtc-signer receive loops:

Site Was Now
`shouldAcceptNativeFROSTMessage` returns false silent drop `evidence.RecordReject(senderID, "validation_gate_rejected")` + drop
First-write-wins conflict in assembly loop warn log only `evidence.RecordConflict(senderID)` + warn log

Test coverage (15 new cases)

  • 7 recorder tests: accumulation, per-reason quota saturation, per-reason independence, conflict saturation, all-categories-present, NoOp-inert, RFC-constant assertions
  • 5 policy tests: single reject excludes, single conflict excludes, reject+conflict on different senders, empty evidence (sanity), threshold-constant assertions
  • Receive-loop wiring is covered indirectly by the recorder unit tests; the NoOp default keeps pre-RFC-21 receive semantics observably unchanged so no integration-level test is required.

Verification

Command Result
`go build ./...` + `go build -tags 'frost_native frost_tbtc_signer frost_roast_retry' ./...` both clean
`go test ./pkg/frost/...` + race pass
`go test -tags 'frost_native frost_tbtc_signer frost_roast_retry' ./pkg/frost/...` pass (5 packages)
`staticcheck -checks '-SA1019' ./pkg/frost/...` silent
`go vet ./pkg/frost/...` + `gofmt -l ./pkg/frost/` clean

RFC-21 status

With this PR, all four ROAST evidence categories are operational.
M4 from the original PR #3866 review is fully closed. The
keep-core code arc for RFC-21 is now feature-complete; remaining
work is operations-side (integration testnet, manifest flip).

Test plan

  • CI green.
  • Reviewer confirms the per-reason quota independence is the right semantics (alternative: single per-sender reject counter).
  • Reviewer confirms threshold = 1 for both reject and conflict (alternative: higher to absorb noise; trade-off is faster vs slower exclusion of misbehaving peers).

Closes the M4 gap from the original PR #3866 review by adding the
two evidence categories the RFC-21 Phase-2 work left as future
work: validation-rejection evidence and first-write-wins-conflict
evidence. With this PR, the NextAttempt policy can permanently
exclude misbehaving peers on all four ROAST blame channels --
transport-overflow, validation-reject, equivocation-conflict, and
silence -- instead of just overflow + silence.

Why this matters: a peer that only sends malformed messages
(validation rejects, never overflows the channel) was previously
indistinguishable from a silent peer. The transient silence-
parking policy would bench-and-reinstate them indefinitely, never
permanently excluding the malicious behaviour. Same for a peer
equivocating mid-attempt: the existing first-write-wins assembly
correctly dropped the conflicting retransmission but only logged
the event -- the bundle carried no structured evidence the
coordinator's policy could act on.

* pkg/frost/roast/attempt/evidence_recorder.go
  - EvidenceRecorder interface gains RecordReject(sender, reason)
    and RecordConflict(sender).
  - RejectQuotaDefault = 8, ConflictQuotaDefault = 4 (matches
    categoryQuota in RFC-21 Layer A).
  - Evidence struct extended with Rejects
    (map[MemberIndex][]RejectEntry: per-(sender, reason)) and
    Conflicts (map[MemberIndex]uint).
  - boundedRecorder: per-reason quota counter keeps each reason
    bucket independent so a peer cannot saturate one reason to
    mask another. Conflicts counter saturates at the conflict
    quota.
  - noOpRecorder: every category discards.
  - NewBoundedRecorderWithQuotas(overflow, reject, conflict)
    constructor for tests; existing NewBoundedRecorderWithQuota
    preserved for backward compat (defaults reject + conflict
    quotas).

* pkg/frost/roast/transition_message.go
  - RejectEntry (Sender + Reason + Count) and ConflictEntry
    (Sender + Count) wire types added.
  - LocalEvidenceSnapshot gains Rejects []RejectEntry and
    Conflicts []ConflictEntry, both omitempty.
  - NewLocalEvidenceSnapshot canonicalises into sorted slices:
    rejects ascending by Sender then by Reason; conflicts
    ascending by Sender.
  - Evidence() reconstructs the map form for downstream
    consumption.
  - Validate() enforces sorted-ascending invariants on both new
    slices.

* pkg/frost/roast/next_attempt.go
  - RejectExclusionThreshold = 1; ConflictExclusionThreshold = 1
    (per RFC-21 Layer B).
  - computeNextAttempt now consults rejectBlamedSenders and
    conflictBlamedSenders alongside the existing overflowBlamed
    set. All three feed into the permanent ExcludedSet.
  - blamedSenders helper factored to share the
    threshold-comparison + sort logic across the three category
    helpers.

* pkg/frost/signing/native_frost_protocol_frost_native.go and
* pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go
  - Three reject sites: in each of the three receive loops, the
    shouldAcceptNativeFROSTMessage failure path now calls
    evidence.RecordReject(senderID, "validation_gate_rejected")
    before returning. (Previously the message was just dropped.)
  - Three conflict sites: the first-write-wins assembly loop's
    "dropping conflicting" branch now calls
    evidence.RecordConflict(senderID) immediately before the
    existing log line. (Previously only the log line.)

Tests (15 new cases):

* pkg/frost/roast/attempt/evidence_recorder_categories_test.go (7)
  - RecordReject accumulates by reason
  - RecordReject per-reason quota saturates
  - Per-reason quotas independent across reasons
  - RecordConflict accumulates and saturates
  - All three categories present in Snapshot after mixed input
  - NoOp recorder inert across all categories
  - RFC-quota constants match documented values

* pkg/frost/roast/next_attempt_categories_test.go (5)
  - Single reject crosses threshold -> permanent exclusion
  - Single conflict crosses threshold -> permanent exclusion
  - Reject and conflict on different senders -> both excluded
  - Empty rejects+conflicts -> no exclusion (sanity)
  - Threshold constants match RFC-21

* Receive-loop wiring is covered by existing send/recv tests
  combined with the recorder unit tests; no new behaviour test
  added at the integration level because the NoOp default keeps
  pre-RFC-21 receive semantics observably unchanged.

Verification:

* go build ./... + go build -tags 'frost_native frost_tbtc_signer
  frost_roast_retry' ./...  -- both clean
* go test ./pkg/frost/... + go test -race ./pkg/frost/roast/...
  + go test -tags 'frost_native frost_tbtc_signer
  frost_roast_retry' ./pkg/frost/... -- all pass (5 packages)
* staticcheck -checks '-SA1019' ./pkg/frost/... -- silent
* go vet ./pkg/frost/... + gofmt -l ./pkg/frost/ -- clean

This PR completes M4 from the original PR #3866 review. All four
ROAST evidence categories (overflow, reject, conflict, silence) are
now operational; the NextAttempt policy excludes on the first
three and parks transiently on the fourth, matching RFC-21
Layer B exactly.
@mswilkison mswilkison merged commit 9bc93c1 into feat/frost-schnorr-migration-scaffold May 23, 2026
15 checks passed
@mswilkison mswilkison deleted the feat/frost-roast-reject-conflict-evidence-2026-05-23 branch May 23, 2026 04:10
mswilkison added a commit that referenced this pull request May 23, 2026
…ase-6 milestone) (#3989)

## Summary

Closes the Phase-6 milestone the RFC named but the implementation
skipped: receive callbacks now reject messages whose
\`AttemptContextHash\` does not match the session's bound
\`AttemptContext\`. Default builds and sessions without a
ROAST-attempt binding skip enforcement entirely, so the change
is observationally identical to pre-Phase-6 behaviour outside
the ROAST path.

Stacked on #3988 (M4 closure).

## Why this matters

The Phase 1B \`AttemptContextHash\` field was structural-only
(present, 32 bytes) until now. Senders could populate it but
receivers ignored the value -- meaning a peer could send a
message bound to attempt N to a receiver running attempt N+1 of
the same session and the receiver would accept it as long as
\`SessionID\` matched. This PR closes that gap.

## What lands

| Surface | Behaviour |
|---|---|
| \`verifyMessageAttemptContextHash(msg, sessionID)\` | No binding →
pass (legacy/default). Binding + matching hash → pass. Binding + missing
hash → \`ErrAttemptContextHashMissing\`. Binding + mismatch →
\`ErrAttemptContextHashMismatch\`. |
| \`attemptContextHashCarrier\` interface | One implementation covers
all three FROST/tbtc-signer message types via their existing
\`GetAttemptContextHash\` methods. |
| 3 receive callbacks updated | After
\`shouldAcceptNativeFROSTMessage\`, call
\`verifyMessageAttemptContextHash\`. Failure →
\`evidence.RecordReject(senderID, "attempt_context_hash_mismatch")\` so
the policy can permanently exclude peers that consistently send
stale-attempt messages. |

## Test coverage

| File | Build | Cases |
|---|---|---|
| \`attempt_context_binding_validation_frost_native_test.go\` |
\`frost_native && frost_roast_retry\` | 5 (no-binding, matching,
missing, mismatch, real-message integration with rebind) |
| \`attempt_context_binding_validation_default_build_test.go\` |
\`frost_native && !frost_roast_retry\` | 1 (default build always passes;
rollback promise upheld) |

## Migration path

1. **Phase 1B (already shipped):** field structurally validated when
present, optional otherwise.
2. **This PR:** enforced *only* when the session has a ROAST-attempt
binding. Default builds and non-ROAST tagged sessions continue to ignore
the field.
3. **Future PR:** once production has rolled out a version that
populates the field on every outbound message, enforcement can be made
unconditional.

## Verification

| Command | Result |
|---|---|
| \`go build ./...\` + tagged | both clean |
| \`go test ./pkg/frost/...\` | pass |
| \`go test -tags 'frost_native frost_tbtc_signer'\` | pass |
| \`go test -tags 'frost_native frost_tbtc_signer frost_roast_retry'\` |
pass |
| \`staticcheck -checks '-SA1019' ./pkg/frost/...\` | silent |
| \`go vet ./pkg/frost/...\` | clean |
| \`gofmt -l ./pkg/frost/signing/\` | silent |

## Test plan

- [ ] CI green.
- [ ] Reviewer confirms the "no binding = skip enforcement" gate is
acceptable (alternative: always enforce when build tag set, regardless
of binding -- riskier during transitions).
- [ ] Reviewer confirms the failure-mode rejects record evidence rather
than just dropping (so misbehaving peers accumulate exclusion-worthy
counts).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant