Skip to content

Commit 4431a29

Browse files
committed
feat(frost/signing): RFC-21 Phase 4.3 -- submit signed snapshots on attempt completion
Wires the three FROST/tbtc-signer receive loops to push their accumulated evidence into the ROAST coordinator state machine at end-of-collect. Submission is a deferred call so it runs on both the success and error return paths. The orchestration that populates the session-handle binding (SetCurrentAttemptHandleForSession) is Phase 5 work, so the submission path is dormant in production deployments today: the helper sees no binding and returns silently. The code path is unit-tested via a binding installed by the test itself, so regressions land at code review. Files: * pkg/frost/signing/roast_retry_attempt_handle_default_build.go (//go:build !frost_roast_retry) - SetCurrentAttemptHandleForSession, ClearCurrentAttemptHandleForSession, ResetSessionHandleRegistryForTest, and currentAttemptHandleForCollect are no-op / always-false stubs. * pkg/frost/signing/roast_retry_attempt_handle_frost_roast_retry.go (//go:build frost_roast_retry) - sync.RWMutex-protected map from sessionID to sessionAttemptBinding{handle, context}. - Set / Clear / Reset functions; later-binding-wins by design (a session whose attempt has transitioned re-binds). * pkg/frost/signing/roast_retry_submit.go - submitSnapshotIfActive(sessionID, recorder): no-op unless all of (registry populated, session bound, recorder non-empty); when all three hold, builds a signed LocalEvidenceSnapshot and calls Coordinator.RecordEvidence. Errors logged at WARN, never propagated, so a transient submission failure cannot break the signing flow. - buildSignedSnapshot helper isolates the canonicalisation + signing chain so failures are surfaced precisely in logs. * native_frost_protocol_frost_native.go (round-one + round-two callers) and native_ffi_primitive_transitional_frost_native.go (tbtc-signer contribution caller) - Capture the recorder by name (no longer inline). - defer submitSnapshotIfActive(request.SessionID, recorder) before calling collect, so submission runs on both success and error returns. Tests (7 cases in roast_retry_submit_frost_roast_retry_test.go, plus the default-build path is exercised by existing tests): * submit no-op when registry empty * submit no-op when session unbound * submit no-op when recorder snapshot is empty * submit signed snapshot with the right SenderID, signed payload, and overflow contents when bound + populated * SetCurrentAttemptHandleForSession: later binding overwrites earlier * Clear removes the binding * RecordEvidence error is logged, not propagated; caller does not observe failure All pass under: go test ./pkg/frost/signing/..., go test -tags 'frost_roast_retry' ./pkg/frost/signing/..., go test -race -tags 'frost_native frost_tbtc_signer frost_roast_retry' ./pkg/frost/signing/..., staticcheck -checks '-SA1019' ./pkg/frost/..., gofmt -l ./pkg/frost/signing/, go vet ./pkg/frost/.... Stacked on Phase 4.2 (#3973). Phase 4.4 will add the soak / fault-injection harness.
1 parent 1438837 commit 4431a29

6 files changed

Lines changed: 561 additions & 11 deletions

pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -861,15 +861,19 @@ func buildTaggedTBTCSignerRoundContributions(
861861
return nil, fmt.Errorf("cannot send round contribution message: [%w]", err)
862862
}
863863

864-
// RFC-21 Phase 4.2: recorder comes from the roast-retry
865-
// registry. NoOp fallback when nothing is registered preserves
866-
// Phase 2 receive semantics.
864+
// RFC-21 Phase 4.2/4.3: recorder comes from the roast-retry
865+
// registry; deferred submission pushes the snapshot into
866+
// Coordinator.RecordEvidence at end-of-collect. NoOp fallback
867+
// when nothing is registered preserves Phase 2 receive
868+
// semantics.
869+
contributionsRecorder := roastRetryRecorderForCollect()
870+
defer submitSnapshotIfActive(request.SessionID, contributionsRecorder)
867871
peerMessages, err := collectBuildTaggedTBTCSignerRoundContributionMessages(
868872
ctx,
869873
request,
870874
includedMembersSet,
871875
includedMembersIndexes,
872-
roastRetryRecorderForCollect(),
876+
contributionsRecorder,
873877
)
874878
if err != nil {
875879
return nil, err

pkg/frost/signing/native_frost_protocol_frost_native.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,21 +350,23 @@ func executeNativeFROSTSigning(
350350
return nil, fmt.Errorf("cannot send native FROST round one message: [%w]", err)
351351
}
352352

353-
// RFC-21 Phase 4.2: the recorder comes from the per-process
353+
// RFC-21 Phase 4.2/4.3: the recorder comes from the per-process
354354
// roast-retry registry. When the registry is empty (default
355355
// build, or no caller has registered a coordinator), the helper
356356
// returns attempt.NoOpRecorder() and behaviour matches Phase 2.
357357
// When the registry has a coordinator, the helper returns a
358358
// fresh BoundedRecorder so overflow drops at the receive
359-
// callback are captured. PR 4.3 will read this recorder's
360-
// Snapshot at end-of-collect and submit the result via
361-
// Coordinator.RecordEvidence.
359+
// callback are captured. The deferred submitSnapshotIfActive
360+
// reads the recorder's Snapshot at end-of-collect and submits
361+
// the result via Coordinator.RecordEvidence.
362+
roundOneRecorder := roastRetryRecorderForCollect()
363+
defer submitSnapshotIfActive(request.SessionID, roundOneRecorder)
362364
roundOneMessages, err := collectNativeFROSTRoundOneMessages(
363365
ctx,
364366
request,
365367
includedMembersSet,
366368
includedMembersIndexes,
367-
roastRetryRecorderForCollect(),
369+
roundOneRecorder,
368370
)
369371
if err != nil {
370372
return nil, err
@@ -440,13 +442,16 @@ func executeNativeFROSTSigning(
440442
return nil, fmt.Errorf("cannot send native FROST round two message: [%w]", err)
441443
}
442444

443-
// RFC-21 Phase 4.2 recorder source -- see round-one caller above.
445+
// RFC-21 Phase 4.2/4.3 recorder source + deferred submission --
446+
// see round-one caller above.
447+
roundTwoRecorder := roastRetryRecorderForCollect()
448+
defer submitSnapshotIfActive(request.SessionID, roundTwoRecorder)
444449
roundTwoMessages, err := collectNativeFROSTRoundTwoMessages(
445450
ctx,
446451
request,
447452
includedMembersSet,
448453
includedMembersIndexes,
449-
roastRetryRecorderForCollect(),
454+
roundTwoRecorder,
450455
)
451456
if err != nil {
452457
return nil, err
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//go:build !frost_roast_retry
2+
3+
package signing
4+
5+
import (
6+
"github.com/keep-network/keep-core/pkg/frost/roast"
7+
"github.com/keep-network/keep-core/pkg/frost/roast/attempt"
8+
)
9+
10+
// SetCurrentAttemptHandleForSession is a no-op in the default build:
11+
// the receive loops will never find a handle for any session, so the
12+
// snapshot submission path is dormant. The build-tagged
13+
// implementation does the real registration.
14+
func SetCurrentAttemptHandleForSession(
15+
_ string,
16+
_ roast.AttemptHandle,
17+
_ attempt.AttemptContext,
18+
) {
19+
}
20+
21+
// ClearCurrentAttemptHandleForSession is a no-op in the default
22+
// build.
23+
func ClearCurrentAttemptHandleForSession(_ string) {}
24+
25+
// ResetSessionHandleRegistryForTest is a no-op in the default
26+
// build.
27+
func ResetSessionHandleRegistryForTest() {}
28+
29+
// currentAttemptHandleForCollect always returns ok=false in the
30+
// default build, so submitSnapshotIfActive exits without attempting
31+
// the RecordEvidence call.
32+
func currentAttemptHandleForCollect(
33+
_ string,
34+
) (roast.AttemptHandle, attempt.AttemptContext, bool) {
35+
return roast.AttemptHandle{}, attempt.AttemptContext{}, false
36+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//go:build frost_roast_retry
2+
3+
package signing
4+
5+
import (
6+
"sync"
7+
8+
"github.com/keep-network/keep-core/pkg/frost/roast"
9+
"github.com/keep-network/keep-core/pkg/frost/roast/attempt"
10+
)
11+
12+
// sessionAttemptBinding records the current attempt's handle and
13+
// context for a session. The orchestration layer (Phase 5+) sets
14+
// the binding via SetCurrentAttemptHandleForSession before driving
15+
// the round-one / round-two / contribution receive loops; the
16+
// receive loops read it at end-of-collect to know which attempt to
17+
// submit their evidence snapshot against.
18+
type sessionAttemptBinding struct {
19+
handle roast.AttemptHandle
20+
context attempt.AttemptContext
21+
}
22+
23+
var (
24+
sessionAttemptBindingMu sync.RWMutex
25+
sessionAttemptBindings = map[string]sessionAttemptBinding{}
26+
)
27+
28+
// SetCurrentAttemptHandleForSession records the in-flight attempt
29+
// handle and context for the named session. Callers in the
30+
// orchestration layer (Phase 5+) invoke this immediately after
31+
// Coordinator.BeginAttempt so receive loops can correlate their
32+
// captured evidence with the right attempt.
33+
//
34+
// Later calls for the same session overwrite earlier ones (this is
35+
// the documented behaviour: a session whose attempt has transitioned
36+
// re-binds to the new attempt's handle).
37+
func SetCurrentAttemptHandleForSession(
38+
sessionID string,
39+
handle roast.AttemptHandle,
40+
ctx attempt.AttemptContext,
41+
) {
42+
sessionAttemptBindingMu.Lock()
43+
defer sessionAttemptBindingMu.Unlock()
44+
sessionAttemptBindings[sessionID] = sessionAttemptBinding{
45+
handle: handle,
46+
context: ctx,
47+
}
48+
}
49+
50+
// ClearCurrentAttemptHandleForSession removes any binding for the
51+
// named session. Callers invoke this when a session terminates so
52+
// the registry does not grow unbounded.
53+
func ClearCurrentAttemptHandleForSession(sessionID string) {
54+
sessionAttemptBindingMu.Lock()
55+
defer sessionAttemptBindingMu.Unlock()
56+
delete(sessionAttemptBindings, sessionID)
57+
}
58+
59+
// ResetSessionHandleRegistryForTest clears every binding. Exposed
60+
// only for tests; not for production code paths.
61+
func ResetSessionHandleRegistryForTest() {
62+
sessionAttemptBindingMu.Lock()
63+
defer sessionAttemptBindingMu.Unlock()
64+
sessionAttemptBindings = map[string]sessionAttemptBinding{}
65+
}
66+
67+
// currentAttemptHandleForCollect reads the binding the orchestration
68+
// layer set for this session. Returns (zero, zero, false) when no
69+
// binding exists -- the typical Phase-4 state, where no orchestration
70+
// is wired yet. The submit helper takes ok=false as the signal to
71+
// skip the RecordEvidence call.
72+
func currentAttemptHandleForCollect(
73+
sessionID string,
74+
) (roast.AttemptHandle, attempt.AttemptContext, bool) {
75+
sessionAttemptBindingMu.RLock()
76+
defer sessionAttemptBindingMu.RUnlock()
77+
binding, ok := sessionAttemptBindings[sessionID]
78+
if !ok {
79+
return roast.AttemptHandle{}, attempt.AttemptContext{}, false
80+
}
81+
return binding.handle, binding.context, true
82+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package signing
2+
3+
import (
4+
"github.com/ipfs/go-log/v2"
5+
"github.com/keep-network/keep-core/pkg/frost/roast"
6+
"github.com/keep-network/keep-core/pkg/frost/roast/attempt"
7+
"github.com/keep-network/keep-core/pkg/protocol/group"
8+
)
9+
10+
// roastRetryLogger is the logger the snapshot-submission path uses
11+
// for non-fatal diagnostics (submission failures, signature errors).
12+
// A submission failure does not propagate to the signing flow:
13+
// Phase 4 ships the submission code path unused in production, and
14+
// even when wired (Phase 5+) a transient submission failure is
15+
// recoverable by the next attempt's evidence flow.
16+
var roastRetryLogger = log.Logger("keep-frost-roast-retry")
17+
18+
// submitSnapshotIfActive is invoked at end-of-collect to push the
19+
// receive loop's accumulated evidence into the ROAST coordinator's
20+
// RecordEvidence pipeline. The function is a no-op when any of the
21+
// following is true:
22+
//
23+
// - the ROAST-retry registry is empty (default build, no caller
24+
// has invoked RegisterRoastRetryCoordinator);
25+
// - no session-handle binding exists for sessionID (the typical
26+
// Phase-4 state, where the orchestration layer that calls
27+
// SetCurrentAttemptHandleForSession is not yet implemented);
28+
// - the recorder is a NoOp (no events were captured).
29+
//
30+
// When all three preconditions hold, the function builds a
31+
// LocalEvidenceSnapshot, signs it with the registered Signer, and
32+
// submits it via Coordinator.RecordEvidence. Errors at any step are
33+
// logged at WARN level and otherwise swallowed -- snapshot
34+
// submission must not break the receive loop's primary signing
35+
// behaviour.
36+
func submitSnapshotIfActive(
37+
sessionID string,
38+
recorder attempt.EvidenceRecorder,
39+
) {
40+
if recorder == nil {
41+
return
42+
}
43+
deps, ok := RegisteredRoastRetryCoordinator()
44+
if !ok {
45+
return
46+
}
47+
handle, ctx, ok := currentAttemptHandleForCollect(sessionID)
48+
if !ok {
49+
return
50+
}
51+
evidence := recorder.Snapshot()
52+
if len(evidence.Overflows) == 0 {
53+
// Nothing observed worth submitting; emitting an empty
54+
// snapshot is still meaningful in the ROAST protocol
55+
// (proof-of-attendance) but adds noise to the bundle.
56+
// Phase 4.3 chooses to skip empty submissions; Phase 5
57+
// orchestration may revisit this if attestations need to
58+
// be unconditional.
59+
return
60+
}
61+
snap := buildSignedSnapshot(deps, ctx, evidence)
62+
if snap == nil {
63+
return
64+
}
65+
if err := deps.Coordinator.RecordEvidence(handle, snap); err != nil {
66+
roastRetryLogger.Warnf(
67+
"roast-retry: RecordEvidence failed for session %q: %v",
68+
sessionID,
69+
err,
70+
)
71+
}
72+
}
73+
74+
// buildSignedSnapshot constructs and signs a LocalEvidenceSnapshot
75+
// from the captured evidence. Returns nil and logs on signature
76+
// failure; callers treat nil as "skip submission" and continue.
77+
func buildSignedSnapshot(
78+
deps RoastRetryDeps,
79+
ctx attempt.AttemptContext,
80+
evidence attempt.Evidence,
81+
) *roast.LocalEvidenceSnapshot {
82+
snap := roast.NewLocalEvidenceSnapshot(
83+
group.MemberIndex(deps.SelfMember),
84+
ctx.Hash(),
85+
evidence,
86+
)
87+
payload, err := roast.CanonicalSnapshotBytes(snap)
88+
if err != nil {
89+
roastRetryLogger.Warnf(
90+
"roast-retry: canonicalising snapshot failed: %v",
91+
err,
92+
)
93+
return nil
94+
}
95+
sig, err := deps.Signer.Sign(payload)
96+
if err != nil {
97+
roastRetryLogger.Warnf(
98+
"roast-retry: signing snapshot failed: %v",
99+
err,
100+
)
101+
return nil
102+
}
103+
snap.OperatorSignature = sig
104+
return snap
105+
}

0 commit comments

Comments
 (0)