diff --git a/cmd/flags.go b/cmd/flags.go index 6ce094c2e6..097a673466 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -308,6 +308,15 @@ func initTbtcFlags(cmd *cobra.Command, cfg *config.Config) { tbtc.DefaultKeyGenerationConcurrency, "tECDSA key generation concurrency.", ) + + cmd.Flags().StringVar( + &cfg.Tbtc.FrostSigningBackend, + "tbtc.frostSigningBackend", + "", + "FROST signing backend name (legacy, native, ffi). "+ + "`native` allows transitional legacy fallback; `ffi` requires native execution. "+ + "Empty value selects legacy.", + ) } // Initialize flags for Maintainer configuration. diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 58ee1249ae..cee7fd2ed8 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -225,6 +225,13 @@ var cmdFlagsTests = map[string]struct { expectedValueFromFlag: 101, defaultValue: runtime.GOMAXPROCS(0), }, + "tbtc.frostSigningBackend": { + readValueFunc: func(c *config.Config) interface{} { return c.Tbtc.FrostSigningBackend }, + flagName: "--tbtc.frostSigningBackend", + flagValue: "native", + expectedValueFromFlag: "native", + defaultValue: "", + }, "maintainer.bitcoinDifficulty": { readValueFunc: func(c *config.Config) interface{} { return c.Maintainer.BitcoinDifficulty.Enabled }, flagName: "--bitcoinDifficulty", diff --git a/docs/development/frost-roast-retry-rollout.adoc b/docs/development/frost-roast-retry-rollout.adoc new file mode 100644 index 0000000000..610fbfe6ea --- /dev/null +++ b/docs/development/frost-roast-retry-rollout.adoc @@ -0,0 +1,132 @@ += FROST/ROAST Retry Rollout Guide + +*Author:* Threshold Labs +*Status:* Draft +*Date:* 2026-05-23 + +== Summary + +This document describes the operational lifecycle of the +ROAST-driven retry path introduced by RFC-21 +(`docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc`) +and implemented across Phases 1-7 of that RFC. It is intended for +node operators and release engineers planning a rollout of the new +retry semantics. + +The feature ships as a build-tagged code path. A production +binary built without the tag contains *no ROAST retry code*; +every signing flow uses the pre-RFC-21 legacy retry shuffle. A +binary built with the tag still executes the legacy path unless +the operator explicitly opts in via an environment variable, and +even then the new path silently falls back to legacy whenever its +preconditions are not met. + +== Activation prerequisites + +All three must be true at the same time for the ROAST retry path +to influence participant selection on a given session attempt: + +. *Build tag set.* The keep-core binary is built with + `-tags frost_roast_retry`. Without the tag, the dispatcher + package does not include the ROAST selector at all. +. *Operator opt-in env var.* The runtime environment defines + `KEEP_CORE_FROST_ROAST_RETRY_ENABLED=true` (case-insensitive, + whitespace-trimmed). The variable is read per call (not + cached), so an operator can flip the switch during a debugging + session without restarting the node. +. *Coordinator registered.* A caller has invoked + `signing.RegisterRoastRetryCoordinator(deps)` at process + startup with the node's operator-key signer, the network's + signature verifier, and the node's member index. + +When any of these is missing, the receive loops, executor +adapter, and signing-loop selector all behave as in the legacy +pre-RFC-21 path. The behavioural rollback is therefore *configuration- +only*: toggle the env var off and the next signing attempt uses +the legacy retry shuffle. + +== Behavioural matrix + +[options="header"] +|=== +| Build tag | Env var | Registry | Bundle present | Behaviour +| not set | _any_ | _any_ | _any_ | Legacy retry shuffle +| set | unset | _any_ | _any_ | Legacy retry shuffle (env-var gate) +| set | true | empty | _any_ | Legacy retry shuffle (no coordinator) +| set | true | populated | absent | Legacy retry shuffle (first attempt / no transition yet) +| set | true | populated | present | ROAST `EvaluateRoastRetryForSigning` +|=== + +The bundle is "present" once the elected coordinator's node has +produced a `TransitionMessage` at the end of a prior attempt +(see Phase 7.1 in RFC-21). Until that happens, the ROAST path is +dormant and the legacy path provides liveness. + +== Error handling discipline + +The orchestration layer distinguishes two error classes: + +* *Static-configuration errors.* Env var unset, no coordinator + registered, signer-material format not extractable. These are + deterministic per deployment configuration: every honest signer + observes the same outcome. Logged at INFO, signing flow + continues with the legacy retry shuffle. + +* *Runtime state-machine errors.* `Coordinator.BeginAttempt` + failures, internal invariant violations, + `ErrAttemptInfeasible` from the policy's threshold floor. These + are non-deterministic across nodes. Treated as *hard failures*: + the session is declared failed and the operator is notified + via the standard signing-failure log path. Falling back to + legacy on these errors would let one node use legacy retry + while another uses ROAST, which would split the signing group + on `NextAttempt` agreement. + +This discipline is the load-bearing safety property of the +RFC-21 design and is enforced in +`pkg/frost/signing/roast_retry_executor_entry_frost_native.go`. + +== Production rollout sequencing + +. *Build the binary with the tag.* Internal builds and CI + pipelines already exercise the tag via + `go test -tags 'frost_roast_retry' ./pkg/frost/... ./pkg/tbtc/...`. + Production binaries adopt the tag once the readiness manifest + in the cross-repo tBTC monorepo's `docs/operations/` directory + flips to `present`. +. *Verify FROST/UniFFI V1 migration.* The DKG-pubkey extraction + helper rejects FrostUniFFIV1 signer material. The Phase 7 + manifest flip is gated on verified migration off V1 across + production signers; until that migration completes, ROAST + retry would silently fall back to legacy on V1-bearing nodes. +. *Stage operator opt-in.* Operators set + `KEEP_CORE_FROST_ROAST_RETRY_ENABLED=true` on a subset of nodes + first. Static-configuration fallback guarantees mixed-state + deployments stay correct: nodes without the env var simply use + legacy. Beware: a node with the env var set but no registered + coordinator (e.g., due to a misconfigured startup script) still + uses legacy, so the safety property holds. +. *Monitor for runtime hard-failures.* The "ROAST orchestration" + log lines under + `keep-frost-roast-orchestration` and + `keep-frost-roast-retry` loggers indicate transitions of the + new state machine. A spike in WARN/ERROR entries from these + loggers is the early signal of trouble. +. *Roll back via env var.* If anything misbehaves, unset + `KEEP_CORE_FROST_ROAST_RETRY_ENABLED` and restart (or wait for + the per-call check to flip the next attempt). The legacy code + paths are retained through Phase 6 and 7 deliberately to make + this rollback bit-for-bit safe. + +== Cross-references + +* RFC-21: `docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc` +* Build-tag scaffolding: `pkg/frost/signing/roast_retry_registration_*.go` +* Orchestration entry point: `pkg/frost/signing/roast_retry_orchestration.go` +* Executor-adapter wiring: `pkg/frost/signing/native_ffi_executor_adapter.go` +* Signing-loop dispatcher: `pkg/tbtc/signing_loop_roast_dispatcher.go` +* ROAST-driven selector: `pkg/tbtc/signing_loop_selector_frost_roast_retry.go` +* Bundle registry: `pkg/frost/signing/roast_retry_bundle_registry_*.go` +* Readiness env var: `pkg/frost/signing/roast_retry_readiness.go` +* Coordinator state machine: `pkg/frost/roast/coordinator_state.go` +* Adapter type: `pkg/frost/roast/signing_retry_adapter.go` diff --git a/docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc b/docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc new file mode 100644 index 0000000000..7120c2d9c9 --- /dev/null +++ b/docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc @@ -0,0 +1,49 @@ += RFC-20: Schnorr/FROST Migration Scaffold + +*Author:* Threshold Labs +*Status:* Draft +*Date:* 2026-02-19 + +== Summary + +This RFC introduces the initial keep-core scaffolding for migrating tBTC from +threshold ECDSA signatures to Schnorr/FROST signatures. + +This change does not switch runtime signing logic yet. It defines core data +types and compatibility helpers required by follow-up protocol, chain, and +wallet orchestration changes. + +== Initial Deliverables + +* New `pkg/frost` package with: +** Taproot x-only output key type (`OutputKey`) +** BIP-340 Schnorr signature type (`Signature`) +** Serialization and logging helpers for Schnorr signatures +** Legacy compatibility alias helper: +`HASH160(0x02 || xOnlyOutputKey)` + +== Compatibility Model + +FROST wallets are expected to use 32-byte x-only keys as canonical identifiers. +During migration, legacy 20-byte wallet key hash paths are supported via +compatibility alias: + +---- +walletPubKeyHashCompat = HASH160(0x02 || xOnlyOutputKey) +---- + +== Follow-up Work + +1. Add FROST signer and coordinator interfaces to replace `pkg/tecdsa/signing`. +2. Introduce FROST DKG executor replacing GG18 pre-params and DKG wiring. +3. Update tBTC chain interfaces and wallet registry integration to accept + x-only keys as canonical wallet identities. +4. Update Bitcoin transaction builders to support P2TR key-path spends. +5. Add dual-stack runtime routing: GG18 existing wallets + FROST new wallets. +6. Add full integration tests for mixed wallet generations and migration flows. + +== Non-Goals (This RFC Revision) + +* No production FROST coordinator implementation. +* No on-chain contract ABI migration in this repository. +* No replacement of existing GG18 runtime paths yet. diff --git a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc new file mode 100644 index 0000000000..f1ddcd5a73 --- /dev/null +++ b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc @@ -0,0 +1,752 @@ += RFC-21: ROAST Coordinator, Retry, and Transition Evidence + +*Author:* Threshold Labs +*Status:* Draft +*Date:* 2026-05-22 + +== Summary + +This RFC defines the protocol layer that lets keep-core honestly advertise its +FROST signing path as ROAST-compliant. Today the package layout names ROAST +concepts (`pkg/frost/roast`, `pkg/frost/retry`) but the actual semantics fall +short of the protocol in two specific places: + +* The retry policy in `pkg/frost/retry/retry.go` is byte-identical to the + tECDSA shuffle in `pkg/tecdsa/retry/retry.go`. It is a deterministic + participant shuffle, not ROAST-aware attempt advancement. +* The three FFI/native-FROST receive loops in `pkg/frost/signing/` drop + channel overflows with `select { default }`, with no bounded transition + evidence and no retransmission contract. + +This RFC proposes a layered design that closes both gaps together, because +they share the same notion of *attempt context* and *transition evidence*. +It is broken into discrete PR-sized phases so the migration can land +incrementally without regressing the existing signing flow. + +== Motivation + +The ROAST paper (Ruffing-Ronge-Aranha-Schneider, CCS 2022) describes a +coordinator-driven retry protocol that turns FROST's brittle round +synchronisation into an asynchronous robust signing primitive. The key +invariants are: + +1. *Attempt context.* Every signing attempt is bound to a deterministic + context (session, key group, message digest, attempt counter, included + participant set). All in-flight protocol messages must reference the + attempt context they belong to. Messages for a stale or future attempt + must not influence the current attempt's transcript. +2. *Transition evidence.* When the coordinator moves an attempt forward it + must publish (or be able to publish on demand) evidence that justifies + the transition: which contributions arrived, which were rejected, which + peers failed to respond within the attempt's bound, and what new + exclusion set the next attempt should use. This is what makes the + protocol *robust* rather than relying on optimistic liveness. +3. *Deterministic exclusion.* The next attempt's participant set is a pure + function of the previous attempt's transition evidence (plus the + original group + seed). Two honest coordinators driving the same session + must arrive at the same attempt context. + +The byte-identical `EvaluateRetryParticipantsForSigning` shuffle satisfies +none of these. It re-shuffles the same set deterministically using +`(seed, retryCount)`. It has no notion of which participants were +*blamable* in the previous attempt, no exclusion ledger, and no message +context binding. + +The receive-loop drop is more subtle but equally protocol-violating: a +silent drop on channel overflow means that two participants observing the +same network can end up with divergent transcripts -- one with the +contribution, one without -- and there is no evidence trail to detect or +recover from that divergence. The `select { default }` pattern is fine for +optimistic transport but not as the canonical mechanism for protocol +membership. + +The two findings cluster naturally: + +* M4 (the receive drop) is the source of evidence. +* M7 (the retry shuffle) is the consumer of evidence. + +A change that fixes M7 without M4 has nothing to drive retry decisions on. +A change that fixes M4 without M7 produces evidence that no consumer reads. +This RFC therefore treats them as one design split into phases, not as two +independent fixes. + +== Background: ROAST in brief + +For implementers approaching this RFC fresh, the relevant ROAST surface is: + +* A *session* fixes the key group, the message digest, and the original + signer set. +* Each session goes through one or more *attempts*. An attempt is + identified by `(session_id, attempt_number)` and contains an *included + set* of participants and an *excluded set* of participants known to be + unable or unwilling to complete this attempt. +* The *coordinator* of an attempt is selected deterministically from the + included set (this is already implemented in `pkg/frost/roast/coordinator.go` + via `SelectCoordinator`). +* The coordinator collects round-one commitments, round-two signature + shares, then either: +** Aggregates a signature when t-of-n shares arrive within the attempt's + time bound -- the session is done. +** Times out and emits *transition evidence*: the set of peers that did + not contribute on time, and the new excluded set the next attempt + should use. + +The retry shuffle in keep-core's tECDSA path predates ROAST and answers a +different question -- "if signing fails, who do we try next?". It does so +without distinguishing inactive peers from corrupted ones, and it makes no +attempt to construct an evidence trail. That is appropriate for tECDSA +(which has its own malicious-share detection downstream) and inappropriate +for FROST (which expects the coordinator to be the source of truth for +attempt advancement). + +== Current state + +=== Retry layer + +`pkg/frost/retry/retry.go` exports `EvaluateRetryParticipantsForSigning` +and `EvaluateRetryParticipantsForKeyGeneration`. Both are pure shuffles +seeded by `(seed, retryCount)`. The signing variant takes no input from +the previous attempt's transcript. `diff pkg/frost/retry/retry.go +pkg/tecdsa/retry/retry.go` is empty. + +=== Coordinator layer + +`pkg/frost/roast/coordinator.go` exports `SelectCoordinator`. The function +is correct in isolation -- given an included set and an attempt context it +returns a deterministic coordinator -- but there is no consumer of the +selected coordinator's state. Attempt context is reconstructed +implicitly from `(seed, retryCount)` at the retry layer, with no shared +record of which messages arrived in which attempt. + +=== Receive layer + +`pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go` and +`pkg/frost/signing/native_frost_protocol_frost_native.go` together host +three receive loops: + +* `native_ffi_primitive_transitional_frost_native.go:973` -- tbtc-signer + round contribution capture. +* `native_frost_protocol_frost_native.go:568` -- native FROST round-one + commitments. +* `native_frost_protocol_frost_native.go:650` -- native FROST round-two + signature shares. + +All three use the same shape: + +[source,go] +---- +messageChan := make(chan *T, expectedMessagesCount*4+1) +request.Channel.Recv(recvCtx, func(message net.Message) { + // shouldAcceptNativeFROSTMessage(...) filtering ... + select { + case messageChan <- payload: + default: + } +}) +---- + +The channel is generously sized (`expected*4+1`), and the assembly side +applies first-write-wins / equal-or-reject (added in PR #3959). But the +`default` arm is still a *silent drop*. When it triggers, the protocol +has no trail to point to: no log of the dropped sender, no count of +drops by sender, no signal to the coordinator that this peer is being +under-represented. + +== Proposed design + +The design is three layers tied together by a single shared *attempt +context* type. + +=== Shared types + +A new package `pkg/frost/roast/attempt` introduces: + +[source,go] +---- +type AttemptContext struct { + SessionID string + KeyGroupID string + MessageDigest [32]byte + AttemptNumber uint + IncludedSet []group.MemberIndex + ExcludedSet []group.MemberIndex + AttemptSeed [32]byte +} + +func (AttemptContext) Hash() [32]byte +---- + +`AttemptContext.Hash()` becomes the canonical binding for every protocol +message emitted in the attempt -- contribution messages already carry a +`SessionIDValue`; we extend them with an `AttemptContextHash` field so +the receiver can reject stale-attempt messages structurally instead of +relying on session ID alone. + +`AttemptSeed` is widened from `int64` to `[32]byte` and *must* be +derived from inputs the group already agrees on -- specifically: + +[source,go] +---- +AttemptSeed = SHA256( + DkgGroupPublicKey || SessionID || MessageDigest, +) +---- + +This binding prevents a malicious coordinator from picking a seed that +shapes the included set in its favour. The seed is a pure function of +session inputs; it is never chosen, only derived. Any signer can +recompute it from the session header and verify the coordinator's +participant selection. + +*`DkgGroupPublicKey` source.* The runtime extracts `DkgGroupPublicKey` +from the FFI signer material at attempt construction time -- the same +material that already carries the DKG-validated group public key and is +required at signature-verification time anyway. Do not re-read it from +the wallet registry: the FFI material is the canonical hot-path source, +removes async/DB lookup latency, and preserves separation between the +core signing protocol and application state. + +=== Layer A: Receiver transition evidence (M4) + +The three `select { default }` drops become: + +[source,go] +---- +select { +case messageChan <- payload: +default: + evidence.RecordOverflow(payload.SenderID(), attemptCtx) +} +---- + +`evidence` is an `AttemptEvidenceRecorder` instantiated per attempt by +the coordinator-aware caller. It tracks: + +* Overflow events keyed by sender -- a sender that overflows + repeatedly is suspect either of attack or of being on a degraded + link, and the next attempt should treat the channel as evidence + rather than dropping it. +* Reject events keyed by sender and reason + (`shouldAcceptNativeFROSTMessage` returning false already). +* First-write-wins conflicts keyed by sender -- already logged in + PR #3959 but not yet structured into evidence. +* Per-attempt time bound expiry -- which senders failed to respond at + all before the attempt's context deadline. + +The recorder produces a `TransitionEvidence` value when the attempt +completes (either by signature aggregation or by timeout), which the +coordinator consumes. The recorder itself never decides who is excluded; +it only collects. + +Bounded means bounded: the recorder has a fixed-size ring *per sender, +per blame category*. The categories are tracked with separate quotas so +one category cannot mask another -- a peer cannot spam overflow events +to drown out reject evidence or vice-versa: + +[source,go] +---- +type categoryQuota struct { + Overflow uint8 // default 8 + Reject uint8 // default 8 + Conflict uint8 // default 4 + Silence uint8 // default 1 (single bit per attempt deadline) +} +---- + +The point is to produce a fixed-size attestation, not to log +everything forever. Per-attempt evidence is at most +`O(|IncludedSet| * sum(quotas))` bytes -- bounded, predictable, and +small enough to be signed and broadcast as a single message. The +broadcast mechanism is the coordinator-aggregated `TransitionMessage` +defined in the Resolved decisions section. + +=== Layer B: Coordinator state (joining M4 and M7) + +`pkg/frost/roast/coordinator.go` grows from a single selection function +into a state machine: + +[source,go] +---- +type AttemptState int +const ( + AttemptPending AttemptState = iota + AttemptCollecting + AttemptAggregating + AttemptSucceeded + AttemptTransitioned +) + +type Coordinator interface { + BeginAttempt(ctx AttemptContext) (AttemptHandle, error) + RecordEvidence(handle AttemptHandle, evidence TransitionEvidence) error + NextAttempt(handle AttemptHandle) (AttemptContext, error) +} +---- + +`NextAttempt` is the policy function that produces the next attempt's +context from the previous attempt's evidence. It is deterministic given +`(AttemptContext, TransitionEvidence)` -- two coordinators with the same +verified inputs agree on the next attempt without further coordination. + +The verified-inputs requirement is critical: gossip is eventually +consistent, but `NextAttempt` is a synchronous state transition. Two +honest signers fed differently-timed evidence sets produce divergent +contexts. To prevent that, the *evidence input itself* is an +authoritative `TransitionMessage` produced by the current attempt's +coordinator (the "coordinator-aggregation" model defined in the +Resolved decisions section); see that section for the full +agreement-flow specification. + +*Seed-bridging.* The legacy `pkg/frost/roast/coordinator.go::SelectCoordinator` +helper accepts an `int64` seed plus an attempt number. `BeginAttempt` +wraps it with a sterile bridge that folds the new `[32]byte` +`AttemptSeed` into the legacy parameter shape -- for example, taking +the first 8 bytes as a big-endian `int64`. The bridge is a +non-cryptographic adapter for the deterministic shuffle: equivalent +seed bytes must produce the same legacy `int64` on every honest +signer. The bridge is named, isolated, and exhaustively tested so +later edits cannot accidentally desynchronise it. + +The exclusion policy is: + +. Senders with `OverflowCount >= overflowExclusionThreshold` during the + attempt window are moved to `ExcludedSet` (transport blamable). +. Senders with at least one confirmed reject event for non-transport + reasons are moved to `ExcludedSet` (validation blamable). +. Senders with deadline-expiry only -- silent peers -- are moved to a + *parked* set that the next attempt skips but the attempt after that + retries (to tolerate transient outages). Silence parking is + *strictly transient*: a single attempt's worth of skip, no escalation. + A peer falsely labelled silent because their contribution arrived + late (or because a malicious coordinator censored it) is not + permanently penalised -- they are reinstated by the very next + attempt. Permanent exclusion only follows from overflow or non- + transport reject events, neither of which can fire on a slow-but- + honest peer. +. If `IncludedSet` minus exclusions drops below the threshold `t`, the + coordinator returns `ErrAttemptInfeasible` and the session is + declared failed for this signer set. + +The thresholds are *fixed constants* in the initial design, picked to +be evidently small relative to the per-attempt deadline and the +`expectedMessagesCount*4+1` channel capacity: + +[source,go] +---- +const ( + overflowExclusionThreshold = 4 // overflow events per attempt window + rejectExclusionThreshold = 1 // any confirmed non-transport reject + silenceParkingThreshold = 1 // any deadline expiry parks for 1 attempt +) +---- + +Making them constants up-front means honest signers do not need to +negotiate them. If production telemetry indicates a constant is wrong +for the attempt's wall-clock bound, the change is a routine code +update that ships through Phase 7's manifest gate -- not a runtime +parameter that drift can desynchronise. + +=== Layer C: Retry orchestration (M7) + +`pkg/frost/retry/retry.go` is renamed to +`pkg/frost/retry/retry_legacy.go` and kept for the key-generation path +(which already has its own three-tier exclusion structure that is closer +to ROAST semantics). The signing path moves to a thin wrapper around +`Coordinator.NextAttempt`: + +[source,go] +---- +func EvaluateRoastRetryForSigning( + coordinator Coordinator, + handle AttemptHandle, +) ([]group.MemberIndex, AttemptContext, error) +---- + +The byte-identical-to-tECDSA `EvaluateRetryParticipantsForSigning` is +removed once all callers migrate. We keep a `roast.SigningRetryAdapter` +shim implementing the old signature that delegates to the coordinator, +to make the migration mechanical PR-by-PR. + +== Phased implementation + +Each phase is one or two PRs. Phases are linear: later PRs assume +earlier PRs have merged. + +=== Phase 0: This RFC + +Doc-only. Lands first so subsequent code PRs can reference its design +choices in their PR descriptions and reviews. + +=== Phase 1: Attempt context type and hash + +* Add `pkg/frost/roast/attempt` package with `AttemptContext` and + canonical hash. No protocol behaviour changes. +* Extend protocol message structs with `AttemptContextHash` field, with + the field optional during the migration so existing peers stay + compatible. + +=== Phase 2: Receiver overflow tracking (M4 layer A) + +* Introduce `AttemptEvidenceRecorder` interface and a no-op default. +* Plumb the recorder through the three receive loops. Default no-op + preserves exact current behaviour. +* Add unit tests showing the recorder captures overflow without + changing receive semantics in the noop path. + +=== Phase 3: Coordinator state machine + +* Promote `pkg/frost/roast/coordinator.go` to a state-tracking + coordinator. Existing `SelectCoordinator` becomes an internal helper. +* Cover deterministic next-attempt computation under unit tests with + property tests for the + `(AttemptContext, TransitionEvidence) -> AttemptContext` map. +* No production code path uses the new coordinator yet -- it ships + unused. + +=== Phase 4: Wire receiver to coordinator + +* Connect the evidence recorder to a real coordinator instance behind + a new build tag (`frost_roast_retry`). +* Existing receive loops still use the noop recorder; the new code + path is reachable only when the build tag is set. +* Add a soak-style test that drives the full attempt -> evidence -> + next-attempt loop under fault injection (synthetic overflow, + synthetic reject, synthetic silence). + +=== Phase 5: Retry adapter, session orchestration, readiness gate + +* Add `EvaluateRoastRetryForSigning` and `roast.SigningRetryAdapter` + with a `ChainAddressResolver` interface to bridge + `group.MemberIndex` (ROAST namespace) to `chain.Address` (legacy + namespace). +* Wire session orchestration at the layer that constructs + `NativeExecutionFFISigningRequest`: at session start call + `Coordinator.BeginAttempt` and `SetCurrentAttemptHandleForSession`; + on session end call `ClearCurrentAttemptHandleForSession` from a + deferred cleanup so success and failure paths converge. +* Add a TTL eviction sweep over `sessionAttemptBindings` (default + two hours) so a goroutine panic before the deferred clear cannot + leak bindings indefinitely (see Resolved decisions). +* Wire a feature-flagged readiness gate + (`KEEP_CORE_FROST_ROAST_RETRY_ENABLED=true`) so production builds + with the `frost_roast_retry` tag still refuse to wire orchestration + without explicit operator opt-in. The gate matches the precedent + set by `KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP`. + +*Important:* Phase 5 ships *no* signing-call-site migrations. The +adapter exists and is fully wired, but no production receive loop +calls it yet. A partial migration (round-one on ROAST, round-two on +legacy shuffle within the same session) would fracture the +attempt-context binding across the two rounds and disconnect the +evidence captured in round-one from the participant selection in +round-two. The readiness gate -- not partial migration -- is the +risk-management mechanism. + +=== Phase 6: Migrate all signing call sites + +* Migrate *all three* signing call sites onto the adapter in a single + coordinated change: +** `collectNativeFROSTRoundOneMessages` +** `collectNativeFROSTRoundTwoMessages` +** `collectBuildTaggedTBTCSignerRoundContributionMessages` ++ +The three flows share one attempt context per session; migrating +them together preserves the round-to-round evidence binding within a +session. +* Once the legacy `EvaluateRetryParticipantsForSigning` has no + callers, delete it. (Key-generation legacy retry stays.) +* Remove the `frost_roast_retry` build tag; the new retry path is + unconditional once Phase 7's manifest gate flips. + +=== Phase 7: Readiness manifest evidence + +* Update the FROST readiness manifest to flip ROAST retry + + transition evidence from `missing-no-go` to `present` once Phase 6 + ships and the integration test suite has been run against a real + testnet. +* As with every readiness gate in this repo, the manifest is updated + only when the supporting evidence is attached. The RFC does not + promise an early flip. + +== Resolved decisions + +The decisions in this section were settled in a Phase-3 design review +(2026-05-22) with cross-team protocol-owner input. They are listed +here so subsequent implementation PRs can reference them. + +=== Cross-process coordinator agreement + +*Decision: coordinator-proposed aggregation on a dedicated topic, +signed with the operator key, with receiver-side bundle verification +for censorship detection.* + +The earlier draft of this RFC carried "all-to-all signed-evidence +gossip with local union" as the recommended path. That recommendation +silently assumed gossip is synchronously consistent across the signer +set; in practice gossip is eventually consistent, so two honest +signers can hold divergent evidence sets at the moment the attempt +times out. Applying the deterministic `NextAttempt` function to +divergent inputs produces divergent next-attempt contexts and +fractures the signing group. + +The replacement flow is: + +. *Observation.* Each signer's `EvidenceRecorder` (Phase 2) + produces a per-attempt local-evidence snapshot. +. *Submission.* Each signer signs its snapshot with its operator + key (the same key `pkg/net` already uses to attribute network + messages) and broadcasts it on a dedicated evidence topic. +. *Aggregation.* The current attempt's elected coordinator + (the deterministic `SelectCoordinator` output) collects the + signed snapshots, builds a canonical bundle, signs the bundle, + and broadcasts it as a `TransitionMessage`. +. *Verification.* Every receiver validates the bundle's + coordinator signature, validates each contained snapshot's + operator signature, *and verifies that its own observations + appear in the bundle*. A coordinator that omits an honest + peer's signed snapshot is caught here. +. *Transition.* Receivers feed the verified bundle into + `NextAttempt`. Because the bundle is the authoritative input, + all honest receivers compute the same next-attempt context. + +A peer that signs conflicting snapshots is slashable -- the +signature is the binding. A coordinator that signs an inconsistent +bundle (omits observations, alters counts, etc.) is detected at +verification step (4) and the next-attempt coordinator handles the +exclusion. + +Alternatives considered (rejected): + +. *All-to-all signed-evidence gossip with local union.* Original + recommendation. Rejected because gossip's eventual-consistency + semantics let honest signers reach the deterministic + `NextAttempt` boundary with divergent inputs, producing + divergent outputs. +. *Piggy-back on existing FROST broadcast channel.* Rejected + because it couples evidence rate limits to protocol round-trip + rate limits, and re-uses a topic with different traffic + characteristics. +. *Coordinator-only authoritative without aggregation.* Rejected + because losing the all-signer signed attestations also loses + the audit trail. The aggregation model keeps the per-signer + signatures inside the bundle, so the audit trail survives. + +Liveness: a malicious coordinator can withhold the +`TransitionMessage`, stalling the transition. ROAST handles this +the same way it handles a malicious signer: the attempt times +out, the next attempt elects a different coordinator (the +`SelectCoordinator` output is deterministic but rotates with the +attempt number), and the new coordinator drives the transition. +The malicious coordinator's evidence is itself parked or +excluded by the new coordinator's bundle, ending the loop. + +Safety: any honest signer that verifies a bundle and computes +`NextAttempt(ctx, bundle)` produces the same context as any other +honest signer that verifies the same bundle. Safety reduces to +"is the bundle correctly verified" -- a local check, not a +network-consistency requirement. + +This design satisfies the formal verified-inputs requirement of +the deterministic `NextAttempt` policy specified in Layer B. + +=== Source of `DkgGroupPublicKey` for the seed + +*Decision: extract from FFI signer material at attempt construction.* + +The DKG-validated group public key is already present in the FFI +signer material (it is required at signature-verification time +anyway), so the seed derivation can take it from there. The +wallet registry is *not* consulted on the hot path; doing so +would introduce async lookup latency and entangle the core +signing protocol with application state. See Shared types above +for the derivation contract. + +=== `AttemptContext` ↔ `NativeExecutionFFISigningRequest` binding + +*Decision: extend the request struct with an `AttemptContext` +field; the context is Go-side orchestration only.* + +The context does not cross the CGO/Rust boundary into the +`tbtc-signer` engine -- the engine remains a pure signing +primitive. Go-side coordinator wiring populates the context; +existing call sites construct attempt-zero contexts inline +during Phase 4. + +=== `SelectCoordinator` retention + +*Decision: keep the existing helper; bridge the seed type inside +`BeginAttempt`.* + +The deterministic shuffle is correct in isolation. The bridge +folds the new `[32]byte` `AttemptSeed` into the legacy `int64` +parameter shape with a sterile, named adapter (see Layer B). + +=== Evidence-signing key + +*Decision: reuse the existing operator key.* + +The operator key already binds every other gossip message a +keep-core node emits via `pkg/net`. Layering a second key +surface specifically for evidence signing is premature +optimization given the current key model. + +=== Evidence message format + +*Decision: JSON payload wrapped in the existing `pkg/net/gen/pb` +envelope, routed via the `net.Message` interface.* + +This matches the FROST/tbtc-signer protocol messages (Phase 1B) +and inherits the network layer's operator-key signing +automatically. Raw JSON does not appear on the wire. + +=== Maximum evidence-message size + +*Decision: single `TransitionMessage` per transition; no +chunking.* + +Under coordinator-aggregation, the per-transition payload is +`O(N)` not `O(N^2)`. At a 100-signer group with all four +quotas saturated the JSON-encoded bundle is ~10-20 KiB, +comfortably within libp2p's per-message limits. + +=== Session-handle binding TTL eviction + +*Decision: a periodic sweep over `sessionAttemptBindings` +(introduced by Phase 4.3) evicts entries older than a fixed TTL +(default two hours).* + +Phase 4.3's session-handle registry expects the orchestration +layer (Phase 5) to call `ClearCurrentAttemptHandleForSession` +from a deferred cleanup when the session ends. A goroutine +panic before the deferred clear runs would leak the binding +permanently, since nothing else removes it. + +The two-hour TTL is a defence-in-depth backstop. It is long +enough that no real signing session reaches it (typical sessions +complete in seconds; a multi-attempt session under ROAST retry +should not exceed minutes), and short enough that a leaked +binding does not accumulate across days of node uptime. The +sweep itself runs on a background goroutine; entries are evicted +in batches under the registry's existing write lock. + +Phase 5.2 introduces both the sweep goroutine and the timestamp +field on `sessionAttemptBinding`. The eviction does not depend +on session-completion correctness; it only catches the +panic-before-defer pathological case. + +=== Orchestration error taxonomy + +*Decision: orchestration errors are bucketed into two classes, with +fundamentally different handling.* + +When the executor adapter calls `BeginOrchestrationForSession`, +the call can fail for two distinct reasons that the receiver MUST +distinguish: + +. *Static-configuration errors* -- the build was deployed without + the readiness env var, or no caller has registered a coordinator. + These errors are fully deterministic: every honest signer + observes the same error from the same configuration at the same + startup state. Safe to log at INFO and fall back to the legacy + retry path. Sentinel errors `ErrRoastRetryReadinessOptOut` and + `ErrNoRoastRetryCoordinatorRegistered` (introduced in Phase 6.3) + signal this class; `errors.Is` checks identify them. + +. *Runtime state-machine errors* -- `Coordinator.BeginAttempt` + returned an error (out-of-memory, malformed AttemptContext, + internal invariant violated, etc.). These errors are + non-deterministic across nodes: node A may experience a runtime + failure while node B succeeds. Treat them as hard failures: + return an error from the executor adapter, declare the session + failed. + +The safety reason is load-bearing. If node A falls back to the +legacy retry shuffle while node B proceeds with the ROAST state +machine, the two nodes compute different `NextAttempt` participant +sets, and the signing group fractures permanently. The legacy +fallback is only acceptable when every honest signer would make +the same choice, which is true for static configuration and false +for runtime errors. + +This decision applies to Phase 6.3 (orchestration wiring at the +executor adapter) and Phase 6.4 (call-site migration). Phase 5 +deliberately ships orchestration as best-effort because it has no +production consumer; Phase 6 is where the safety distinction +matters. + +=== `FrostUniFFIV1` signer-material prerequisite + +*Decision: Phase 7's manifest flip is gated on verified migration +away from `FrostUniFFIV1` signer material across all production +signers.* + +Phase 6.1's `ExtractDkgGroupPublicKeyFromMaterial` switches on +`NativeSignerMaterial.Format`. The two production-relevant formats +(`FrostUniFFIV2` and `FrostTBTCSignerV1`) expose the DKG group +public key on the material directly. The legacy `FrostUniFFIV1` +format does not include the group key in a form Phase 6.1 can +extract; the helper returns a descriptive error directing +operators to migrate. + +Until the network has fully migrated off V1, the Phase 7 readiness +manifest cannot flip to `present`. The migration tracking +mechanism is out of scope for this RFC; the prerequisite is +documented here as a hard dependency of Phase 7. + +== Open questions + +. *Persistence across signer restart.* If a signer crashes mid-attempt, + does it lose its evidence? The paper assumes persistent state. For + keep-core we likely accept evidence loss on restart at first (the + attempt times out and a new attempt is started fresh) and revisit + persistence in a follow-up RFC once we have wire-level evidence. ++ +*Sketch for Phase 5+:* introduce a `SyncState` gossip message. A +restarting node broadcasts +`(LastKnownAttemptContextHash, KeyGroupID)`; peers reply with their +current attempt and the set of signed attestations they hold for +that attempt. This avoids the timeout-and-restart cost on graceful +redeploys without requiring on-disk persistence -- the peers' +gossiped attestations *are* the persistent record. +. *FFI surface.* `tbtc-signer` (the Rust engine) does not need to know + about ROAST coordinator state -- it remains a pure signing engine. + But it does need to surface structured errors that the coordinator + can map to exclusion reasons. PR #425 / #3961 (the L5 paired + change) is the template for this style of error-code wiring. Future + exclusion-relevant errors should follow the same dedicated-variant + pattern. +. *Backward-compat horizon.* Once the `AttemptContextHash` field is on + protocol messages, how long do we accept messages from peers that + omit it? Proposal: optional during Phase 1-5, required at Phase 6, + validation-rejection at Phase 7. + +== Out of scope + +* DKG retry. The key-generation legacy retry stays. Re-evaluating DKG + retry under ROAST is a separate RFC. +* Bitcoin transaction-builder changes. Witness restoration and + P2WSH/P2TR handling are unaffected. +* Operator UX (CLI flags, dashboards). Whatever is needed lands + alongside Phase 5 / Phase 6 as small, focused PRs. +* Cross-domain ROAST (e.g., between keep-core and tbtc-signer). The + signer remains a single-process engine; coordinator state lives on + the keep-core side. + +== References + +* Ruffing, Ronge, Aranha, Schneider. ``ROAST: Robust Asynchronous + Schnorr Threshold Signatures.'' ACM CCS 2022. +* Komlo, Goldberg. ``FROST: Flexible Round-Optimized Schnorr Threshold + Signatures.'' SAC 2020. +* RFC-20: Schnorr/FROST Migration Scaffold (`docs/rfc/rfc-20-schnorr-frost-migration-scaffold.adoc`). +* Independent review of FROST/ROAST readiness branch: + https://github.com/threshold-network/keep-core/pull/3866. +* L5 paired error-code change: `tlabs-xyz/tbtc#425` (Rust producer) + + `threshold-network/keep-core#3961` (Go consumer). +* Receive-loop drop sites: +** `pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go:973` +** `pkg/frost/signing/native_frost_protocol_frost_native.go:568` +** `pkg/frost/signing/native_frost_protocol_frost_native.go:650` +* Byte-identical retry shuffle: +** `pkg/frost/retry/retry.go` +** `pkg/tecdsa/retry/retry.go` diff --git a/pkg/bitcoin/transaction_builder.go b/pkg/bitcoin/transaction_builder.go index e446f07517..c74b4562eb 100644 --- a/pkg/bitcoin/transaction_builder.go +++ b/pkg/bitcoin/transaction_builder.go @@ -2,6 +2,7 @@ package bitcoin import ( "crypto/ecdsa" + "encoding/hex" "fmt" "math/big" @@ -309,6 +310,165 @@ func (tb *TransactionBuilder) TotalInputsValue() int64 { return totalInputsValue } +// ReplaceUnsignedTransaction replaces the internal unsigned transaction while +// preserving per-input sighash metadata collected during builder input setup. +func (tb *TransactionBuilder) ReplaceUnsignedTransaction( + transaction *Transaction, +) error { + if transaction == nil { + return fmt.Errorf("transaction is nil") + } + + if len(transaction.Inputs) != len(tb.sigHashArgs) { + return fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(transaction.Inputs), + len(tb.sigHashArgs), + ) + } + + previousInputs := tb.internal.TxIn + + replacedInternal := newInternalTransaction() + replacedInternal.fromTransaction(transaction) + + for i := range replacedInternal.TxIn { + previousInput := previousInputs[i] + replacedInput := replacedInternal.TxIn[i] + + if previousInput == nil || replacedInput == nil { + continue + } + + if len(replacedInput.SignatureScript) > 0 { + return fmt.Errorf( + "replacement transaction input [%d] has unexpected non-empty signature script", + i, + ) + } + + if len(replacedInput.Witness) > 0 { + return fmt.Errorf( + "replacement transaction input [%d] has unexpected non-empty witness", + i, + ) + } + + // The replacement's SignatureScript and Witness are both empty here + // because of the two refusals above, so the per-input restore below + // only has to decide what to copy *from* the previous input. + if tb.sigHashArgs[i].witness { + // Witness inputs may carry a single-element pre-signing witness + // that holds a P2WSH-style redeem script. Multi-element witnesses + // belong to P2TR script-path spends or other workflows that are + // not in scope for the current FROST migration, and silently + // dropping them produced malformed transactions later — refuse + // instead so the unsupported case fails loudly. Lifting this to + // support multi-element witnesses requires a per-input policy + // rather than a blanket copy because the replacement could + // legitimately differ in witness shape from the previous input. + switch len(previousInput.Witness) { + case 0: + // Nothing to restore (typical P2TR key-path or P2WPKH). + case 1: + redeemScript := append([]byte{}, previousInput.Witness[0]...) + replacedInput.Witness = wire.TxWitness{redeemScript} + default: + return fmt.Errorf( + "replacement transaction input [%d] previous witness has "+ + "[%d] elements; only zero- or single-element "+ + "pre-signing witnesses are currently supported for "+ + "restoration", + i, + len(previousInput.Witness), + ) + } + } else if len(previousInput.SignatureScript) > 0 { + replacedInput.SignatureScript = append( + []byte{}, + previousInput.SignatureScript..., + ) + } + } + + tb.internal = replacedInternal + tb.sigHashes = nil + + return nil +} + +// UnsignedTransaction returns the current unsigned transaction builder state. +func (tb *TransactionBuilder) UnsignedTransaction() *Transaction { + return tb.internal.toTransaction() +} + +// UnsignedTransactionInput carries canonical unsigned input metadata extracted +// from the builder state. +type UnsignedTransactionInput struct { + TxIDHex string + Vout uint32 + ValueSats uint64 +} + +// UnsignedTransactionOutput carries canonical unsigned output metadata +// extracted from the builder state. +type UnsignedTransactionOutput struct { + ScriptPubKeyHex string + ValueSats uint64 +} + +// UnsignedTransactionIO returns canonical unsigned transaction input/output +// metadata from the builder state. +func (tb *TransactionBuilder) UnsignedTransactionIO() ( + []UnsignedTransactionInput, + []UnsignedTransactionOutput, + error, +) { + if len(tb.internal.TxIn) != len(tb.sigHashArgs) { + return nil, nil, fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(tb.internal.TxIn), + len(tb.sigHashArgs), + ) + } + + inputs := make([]UnsignedTransactionInput, 0, len(tb.internal.TxIn)) + for i, input := range tb.internal.TxIn { + value := tb.sigHashArgs[i].value + if value < 0 { + return nil, nil, fmt.Errorf("input [%d] value is negative", i) + } + + inputs = append( + inputs, + UnsignedTransactionInput{ + // chainhash.Hash.String renders txid in standard Bitcoin display + // (RPC/explorer) byte order, i.e. reversed vs internal bytes. + TxIDHex: input.PreviousOutPoint.Hash.String(), + Vout: input.PreviousOutPoint.Index, + ValueSats: uint64(value), + }, + ) + } + + outputs := make([]UnsignedTransactionOutput, 0, len(tb.internal.TxOut)) + for i, output := range tb.internal.TxOut { + if output.Value < 0 { + return nil, nil, fmt.Errorf("output [%d] value is negative", i) + } + + outputs = append( + outputs, + UnsignedTransactionOutput{ + ScriptPubKeyHex: hex.EncodeToString(output.PkScript), + ValueSats: uint64(output.Value), + }, + ) + } + + return inputs, outputs, nil +} + // inputSigHashArgs is a helper structure holding some arguments required to // compute a sighash for the given input. type inputSigHashArgs struct { diff --git a/pkg/bitcoin/transaction_builder_test.go b/pkg/bitcoin/transaction_builder_test.go index 246e70cd51..8349946f6c 100644 --- a/pkg/bitcoin/transaction_builder_test.go +++ b/pkg/bitcoin/transaction_builder_test.go @@ -4,8 +4,11 @@ import ( "fmt" "math/big" "reflect" + "strings" "testing" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/keep-network/keep-core/internal/testutils" ) @@ -215,6 +218,387 @@ func TestTransactionBuilder_AddOutput(t *testing.T) { assertInternalOutput(t, builder, 0, output) } +func TestTransactionBuilder_ReplaceUnsignedTransaction(t *testing.T) { + builder := NewTransactionBuilder(nil) + + var initialInputHash1 chainhash.Hash + var initialInputHash2 chainhash.Hash + initialInputHash1[0] = 0x11 + initialInputHash2[0] = 0x22 + + builder.internal.AddTxIn( + wire.NewTxIn( + wire.NewOutPoint(&initialInputHash1, 1), + []byte{0xde, 0xad}, + nil, + ), + ) + builder.internal.AddTxIn( + wire.NewTxIn( + wire.NewOutPoint(&initialInputHash2, 2), + nil, + [][]byte{{0xbe, 0xef}}, + ), + ) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 111, scriptCode: []byte{0x51}, witness: false}, + &inputSigHashArgs{value: 222, scriptCode: []byte{0x52}, witness: true}, + ) + builder.sigHashes = []*big.Int{big.NewInt(1), big.NewInt(2)} + + var replacementInputHash1 chainhash.Hash + var replacementInputHash2 chainhash.Hash + replacementInputHash1[0] = 0x33 + replacementInputHash2[0] = 0x44 + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Version: 2, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(replacementInputHash1), + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(replacementInputHash2), + OutputIndex: 8, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 1000, + PublicKeyScript: hexToSlice(t, "0014deadbeef"), + }, + }, + Locktime: 0, + }, + ) + if err != nil { + t.Fatalf("unexpected replacement error: [%v]", err) + } + + if len(builder.sigHashes) != 0 { + t.Fatalf("expected sighashes reset after replacement: [%d]", len(builder.sigHashes)) + } + + // Preserve P2SH/P2WSH placeholder scripts needed for final signature + // application while replacing tx skeleton. + if !reflect.DeepEqual([]byte{0xde, 0xad}, builder.internal.TxIn[0].SignatureScript) { + t.Fatalf( + "unexpected preserved signature script\nexpected: [%x]\nactual: [%x]", + []byte{0xde, 0xad}, + builder.internal.TxIn[0].SignatureScript, + ) + } + + if len(builder.internal.TxIn[1].Witness) != 1 { + t.Fatalf("unexpected preserved witness length: [%d]", len(builder.internal.TxIn[1].Witness)) + } + + if !reflect.DeepEqual([]byte{0xbe, 0xef}, builder.internal.TxIn[1].Witness[0]) { + t.Fatalf( + "unexpected preserved witness script\nexpected: [%x]\nactual: [%x]", + []byte{0xbe, 0xef}, + builder.internal.TxIn[1].Witness[0], + ) + } + + inputs, outputs, err := builder.UnsignedTransactionIO() + if err != nil { + t.Fatalf("unexpected extraction error after replacement: [%v]", err) + } + + if len(inputs) != 2 { + t.Fatalf("unexpected input count after replacement: [%d]", len(inputs)) + } + + if inputs[0].TxIDHex != replacementInputHash1.String() || inputs[0].Vout != 7 { + t.Fatalf("unexpected first input after replacement: [%+v]", inputs[0]) + } + + if inputs[1].TxIDHex != replacementInputHash2.String() || inputs[1].Vout != 8 { + t.Fatalf("unexpected second input after replacement: [%+v]", inputs[1]) + } + + if len(outputs) != 1 { + t.Fatalf("unexpected output count after replacement: [%d]", len(outputs)) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsInputMetadataMismatch( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: 1}) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + }, + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 1, + }, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected input metadata mismatch error") + } + + if !reflect.DeepEqual( + fmt.Sprintf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + 2, + 1, + ), + err.Error(), + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsNonEmptyReplacementSignatureScript( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 1, scriptCode: []byte{0x51}, witness: false}, + ) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + SignatureScript: []byte{0xaa}, + Sequence: 0xffffffff, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected replacement signature script error") + } + + if !strings.Contains( + err.Error(), + "replacement transaction input [0] has unexpected non-empty signature script", + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsNonEmptyReplacementWitness( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 1, scriptCode: []byte{0x51}, witness: true}, + ) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + Witness: wire.TxWitness{[]byte{0xbb}}, + Sequence: 0xffffffff, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected replacement witness error") + } + + if !strings.Contains( + err.Error(), + "replacement transaction input [0] has unexpected non-empty witness", + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsMultiElementPreviousWitness( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + previousInput := wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil) + // Pre-signing witness that mimics a P2TR script-path spend: [script, + // controlBlock]. The restoration path supports only zero- or + // single-element previous witnesses today; the multi-element case must + // fail loudly rather than silently dropping data later in signing. + previousInput.Witness = wire.TxWitness{ + []byte{0x51, 0x52}, + []byte{0xc0, 0xab, 0xcd}, + } + builder.internal.AddTxIn(previousInput) + builder.sigHashArgs = append( + builder.sigHashArgs, + &inputSigHashArgs{value: 1, scriptCode: []byte{0x51}, witness: true}, + ) + + err := builder.ReplaceUnsignedTransaction( + &Transaction{ + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash(txHash), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + }, + ) + if err == nil { + t.Fatal("expected multi-element witness restoration error") + } + + if !strings.Contains( + err.Error(), + "previous witness has [2] elements", + ) { + t.Fatalf("unexpected error: [%v]", err) + } + if !strings.Contains( + err.Error(), + "only zero- or single-element", + ) { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestTransactionBuilder_UnsignedTransactionIO(t *testing.T) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + for i := range txHash { + txHash[i] = byte(i + 1) + } + const expectedTxIDHex = "201f1e1d1c1b1a191817161514131211100f0e0d0c0b0a090807060504030201" + + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 7), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: 1234}) + builder.AddOutput(&TransactionOutput{ + Value: 1000, + PublicKeyScript: hexToSlice(t, "0014deadbeef"), + }) + + inputs, outputs, err := builder.UnsignedTransactionIO() + if err != nil { + t.Fatalf("unexpected extraction error: [%v]", err) + } + + if len(inputs) != 1 { + t.Fatalf("unexpected input count: [%d]", len(inputs)) + } + + if inputs[0].TxIDHex != expectedTxIDHex { + t.Fatalf( + "unexpected input txid\nexpected: [%v]\nactual: [%v]", + expectedTxIDHex, + inputs[0].TxIDHex, + ) + } + + if inputs[0].Vout != 7 { + t.Fatalf("unexpected input vout: [%d]", inputs[0].Vout) + } + + if inputs[0].ValueSats != 1234 { + t.Fatalf("unexpected input value: [%d]", inputs[0].ValueSats) + } + + if len(outputs) != 1 { + t.Fatalf("unexpected output count: [%d]", len(outputs)) + } + + if outputs[0].ScriptPubKeyHex != "0014deadbeef" { + t.Fatalf( + "unexpected output script\nexpected: [%v]\nactual: [%v]", + "0014deadbeef", + outputs[0].ScriptPubKeyHex, + ) + } + + if outputs[0].ValueSats != 1000 { + t.Fatalf("unexpected output value: [%d]", outputs[0].ValueSats) + } +} + +func TestTransactionBuilder_UnsignedTransactionIO_RejectsNegativeInputValue( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: -1}) + builder.AddOutput(&TransactionOutput{ + Value: 1, + PublicKeyScript: hexToSlice(t, "0014aa"), + }) + + _, _, err := builder.UnsignedTransactionIO() + if err == nil { + t.Fatal("expected extraction error") + } +} + +func TestTransactionBuilder_UnsignedTransactionIO_RejectsNegativeOutputValue( + t *testing.T, +) { + builder := NewTransactionBuilder(nil) + + var txHash chainhash.Hash + builder.internal.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&txHash, 0), nil, nil)) + builder.sigHashArgs = append(builder.sigHashArgs, &inputSigHashArgs{value: 1}) + builder.AddOutput(&TransactionOutput{ + Value: -1, + PublicKeyScript: hexToSlice(t, "0014aa"), + }) + + _, _, err := builder.UnsignedTransactionIO() + if err == nil { + t.Fatal("expected extraction error") + } +} + // The goal of this test is making sure that the TransactionBuilder can // produce proper signature hashes and apply signatures for all input types, // i.e. P2PKH, P2WPKH, P2SH, and P2WSH. This test uses transactions that diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index ec5c29d40f..33f65e63be 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1374,35 +1374,79 @@ func (tc *TbtcChain) PastNewWalletRegisteredEvents( ) ([]*tbtc.NewWalletRegisteredEvent, error) { var startBlock uint64 var endBlock *uint64 + var walletID [][32]byte var ecdsaWalletID [][32]byte var walletPublicKeyHash [][20]byte if filter != nil { startBlock = filter.StartBlock endBlock = filter.EndBlock + walletID = filter.WalletID ecdsaWalletID = filter.EcdsaWalletID walletPublicKeyHash = filter.WalletPublicKeyHash } - events, err := tc.bridge.PastNewWalletRegisteredEvents( + return pastNewWalletRegisteredEvents( startBlock, endBlock, + walletID, ecdsaWalletID, walletPublicKeyHash, + tc.bridge, + tc.bridge.PastNewWalletRegisteredEvents, + ) +} + +type pastNewWalletRegisteredEventsFn func( + startBlock uint64, + endBlock *uint64, + ecdsaWalletID [][32]byte, + walletPubKeyHash [][20]byte, +) ([]*tbtcabi.BridgeNewWalletRegistered, error) + +func pastNewWalletRegisteredEvents( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + bridge any, + pastLegacyEvents pastNewWalletRegisteredEventsFn, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + convertedEvents, err := pastNewWalletRegisteredV2Events( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + bridge, ) if err != nil { return nil, err } - convertedEvents := make([]*tbtc.NewWalletRegisteredEvent, 0) - for _, event := range events { - convertedEvent := &tbtc.NewWalletRegisteredEvent{ - EcdsaWalletID: event.EcdsaWalletID, - WalletPublicKeyHash: event.WalletPubKeyHash, - BlockNumber: event.Raw.BlockNumber, + // Fallback for legacy deployments that do not emit NewWalletRegisteredV2. + if len(convertedEvents) == 0 && len(walletID) == 0 { + legacyEvents, err := pastLegacyEvents( + startBlock, + endBlock, + ecdsaWalletID, + walletPublicKeyHash, + ) + if err != nil { + return nil, err } - convertedEvents = append(convertedEvents, convertedEvent) + for _, event := range legacyEvents { + convertedEvent := &tbtc.NewWalletRegisteredEvent{ + WalletID: tbtc.DeriveLegacyWalletID(event.WalletPubKeyHash), + EcdsaWalletID: event.EcdsaWalletID, + WalletPublicKeyHash: event.WalletPubKeyHash, + BlockNumber: event.Raw.BlockNumber, + } + + convertedEvents = append(convertedEvents, convertedEvent) + } } sort.SliceStable( @@ -1412,7 +1456,160 @@ func (tc *TbtcChain) PastNewWalletRegisteredEvents( }, ) - return convertedEvents, err + return convertedEvents, nil +} + +func pastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + bridge any, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + if bridge == nil { + return nil, nil + } + + bridgeValue := reflect.ValueOf(bridge) + pastV2Events := bridgeValue.MethodByName("PastNewWalletRegisteredV2Events") + if !pastV2Events.IsValid() { + return nil, nil + } + + var ( + results []reflect.Value + callErr error + ) + + func() { + defer func() { + if recovered := recover(); recovered != nil { + callErr = fmt.Errorf( + "panic calling PastNewWalletRegisteredV2Events: [%v]", + recovered, + ) + } + }() + + results = pastV2Events.Call( + []reflect.Value{ + reflect.ValueOf(startBlock), + reflect.ValueOf(endBlock), + reflect.ValueOf(walletID), + reflect.ValueOf(ecdsaWalletID), + reflect.ValueOf(walletPublicKeyHash), + }, + ) + }() + + if callErr != nil { + return nil, callErr + } + + if len(results) != 2 { + return nil, fmt.Errorf( + "unexpected PastNewWalletRegisteredV2Events result count: [%v]", + len(results), + ) + } + + if !results[1].IsNil() { + err, ok := results[1].Interface().(error) + if !ok { + return nil, fmt.Errorf( + "unexpected PastNewWalletRegisteredV2Events error type: [%T]", + results[1].Interface(), + ) + } + + return nil, err + } + + eventsValue := results[0] + if eventsValue.Kind() != reflect.Slice { + return nil, fmt.Errorf( + "unexpected PastNewWalletRegisteredV2Events events type: [%v]", + eventsValue.Kind(), + ) + } + + convertedEvents := make([]*tbtc.NewWalletRegisteredEvent, 0, eventsValue.Len()) + for i := 0; i < eventsValue.Len(); i++ { + eventValue := eventsValue.Index(i) + if eventValue.Kind() == reflect.Pointer { + if eventValue.IsNil() { + continue + } + + eventValue = eventValue.Elem() + } + + if eventValue.Kind() != reflect.Struct { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 event kind: [%v]", + eventValue.Kind(), + ) + } + + walletIDField := eventValue.FieldByName("WalletID") + ecdsaWalletIDField := eventValue.FieldByName("EcdsaWalletID") + walletPubKeyHashField := eventValue.FieldByName("WalletPubKeyHash") + if !walletPubKeyHashField.IsValid() { + walletPubKeyHashField = eventValue.FieldByName("WalletPublicKeyHash") + } + rawField := eventValue.FieldByName("Raw") + if !rawField.IsValid() { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 raw event payload at index [%v]", + i, + ) + } + + if rawField.Kind() == reflect.Pointer { + if rawField.IsNil() { + return nil, fmt.Errorf("unexpected nil raw event payload") + } + + rawField = rawField.Elem() + } + + if rawField.Kind() != reflect.Struct { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 raw event payload kind at index [%v]: [%v]", + i, + rawField.Kind(), + ) + } + + blockNumberField := rawField.FieldByName("BlockNumber") + + if !walletIDField.IsValid() || + walletIDField.Type() != reflect.TypeOf([32]byte{}) || + !ecdsaWalletIDField.IsValid() || + ecdsaWalletIDField.Type() != reflect.TypeOf([32]byte{}) || + !walletPubKeyHashField.IsValid() || + walletPubKeyHashField.Type() != reflect.TypeOf([20]byte{}) || + !blockNumberField.IsValid() || + blockNumberField.Kind() != reflect.Uint64 { + return nil, fmt.Errorf( + "unexpected NewWalletRegisteredV2 event shape at index [%v]", + i, + ) + } + + convertedEvents = append( + convertedEvents, + &tbtc.NewWalletRegisteredEvent{ + WalletID: walletIDField.Interface().([32]byte), + EcdsaWalletID: ecdsaWalletIDField.Interface().([32]byte), + WalletPublicKeyHash: walletPubKeyHashField.Interface().([20]byte), + BlockNumber: blockNumberField.Uint(), + }, + ) + } + + return convertedEvents, nil } func (tc *TbtcChain) CalculateWalletID( @@ -1473,7 +1670,17 @@ func (tc *TbtcChain) GetWallet( return nil, fmt.Errorf("cannot parse wallet state: [%v]", err) } + walletID, err := walletIDForWalletPublicKeyHash( + tc.bridge, + walletPublicKeyHash, + ) + if err != nil { + // Fallback for legacy deployments where walletID accessor may not exist. + walletID = tbtc.DeriveLegacyWalletID(walletPublicKeyHash) + } + return &tbtc.WalletChainData{ + WalletID: walletID, EcdsaWalletID: wallet.EcdsaWalletID, MainUtxoHash: wallet.MainUtxoHash, PendingRedemptionsValue: wallet.PendingRedemptionsValue, @@ -1486,6 +1693,82 @@ func (tc *TbtcChain) GetWallet( }, nil } +func (tc *TbtcChain) WalletPublicKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + return resolveWalletPublicKeyHashForWalletID( + walletID, + tc.bridge, + ) +} + +type walletIDForWalletPublicKeyHashFn interface { + WalletID(walletPublicKeyHash [20]byte) ([32]byte, error) +} + +func walletIDForWalletPublicKeyHash( + bridge any, + walletPublicKeyHash [20]byte, +) ([32]byte, error) { + resolver, ok := bridge.(walletIDForWalletPublicKeyHashFn) + if !ok { + return [32]byte{}, fmt.Errorf("wallet ID accessor unavailable") + } + + return resolver.WalletID(walletPublicKeyHash) +} + +type walletPublicKeyHashForWalletIDFn interface { + WalletPubKeyHashForWalletID(walletID [32]byte) ([20]byte, error) +} + +func resolveWalletPublicKeyHashForWalletID( + walletID [32]byte, + bridge any, +) ([20]byte, error) { + resolveCanonical, ok := bridge.(walletPublicKeyHashForWalletIDFn) + + var walletPublicKeyHash [20]byte + var err error + if ok { + walletPublicKeyHash, err = resolveCanonical.WalletPubKeyHashForWalletID(walletID) + } else { + err = fmt.Errorf("wallet public key hash accessor unavailable") + } + + if err == nil { + if walletPublicKeyHash != [20]byte{} { + return walletPublicKeyHash, nil + } + } + + legacyWalletPublicKeyHash, ok := tbtc.WalletPublicKeyHashFromLegacyWalletID(walletID) + if ok { + if err != nil { + logger.Infof( + "canonical wallet public key hash resolution failed for wallet ID [0x%x]; using legacy derivation: [%v]", + walletID, + err, + ) + } + + return legacyWalletPublicKeyHash, nil + } + + if err != nil { + return [20]byte{}, fmt.Errorf( + "cannot resolve wallet public key hash for wallet ID [0x%x]: [%v]", + walletID, + err, + ) + } + + return [20]byte{}, fmt.Errorf( + "wallet public key hash not found for wallet ID [0x%x]", + walletID, + ) +} + func (tc *TbtcChain) OnWalletClosed( handler func(event *tbtc.WalletClosedEvent), ) subscription.EventSubscription { diff --git a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go index e76e6f779f..a862325a69 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go @@ -121,7 +121,7 @@ type WalletsWallet struct { // BridgeMetaData contains all meta data concerning the Bridge contract. var BridgeMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeatTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimage\",\"type\":\"bytes\"},{\"internalType\":\"bool\",\"name\":\"witness\",\"type\":\"bool\"}],\"name\":\"defeatFraudChallenge\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"heartbeatMessage\",\"type\":\"bytes\"}],\"name\":\"defeatFraudChallengeWithHeartbeat\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"}],\"name\":\"fraudChallenges\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"reportedAt\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"resolved\",\"type\":\"bool\"}],\"internalType\":\"structFraud.FraudChallenge\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"}],\"name\":\"notifyFraudChallengeDefeatTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"}],\"internalType\":\"structBitcoinTx.RSVSignature\",\"name\":\"signature\",\"type\":\"tuple\"}],\"name\":\"submitFraudChallenge\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newVault\",\"type\":\"address\"}],\"name\":\"DepositVaultFixed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeatTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"walletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegisteredV2\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"RebateStakingSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletID\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimage\",\"type\":\"bytes\"},{\"internalType\":\"bool\",\"name\":\"witness\",\"type\":\"bool\"}],\"name\":\"defeatFraudChallenge\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"heartbeatMessage\",\"type\":\"bytes\"}],\"name\":\"defeatFraudChallengeWithHeartbeat\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"}],\"name\":\"fraudChallenges\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"reportedAt\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"resolved\",\"type\":\"bool\"}],\"internalType\":\"structFraud.FraudChallenge\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRebateStaking\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"initializeV2_FixVaultZeroDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"}],\"name\":\"notifyFraudChallengeDefeatTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"setRebateStaking\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"}],\"internalType\":\"structBitcoinTx.RSVSignature\",\"name\":\"signature\",\"type\":\"tuple\"}],\"name\":\"submitFraudChallenge\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"walletID\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletId\",\"type\":\"bytes32\"}],\"name\":\"walletPubKeyHashForWalletID\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"walletId\",\"type\":\"bytes32\"}],\"name\":\"walletsByWalletID\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", } // BridgeABI is the input ABI used to generate the binding from. @@ -270,6 +270,37 @@ func (_Bridge *BridgeTransactorRaw) Transact(opts *bind.TransactOpts, method str return _Bridge.Contract.contract.Transact(opts, method, params...) } +// ActiveWalletID is a free data retrieval call binding the contract method 0x160c1730. +// +// Solidity: function activeWalletID() view returns(bytes32) +func (_Bridge *BridgeCaller) ActiveWalletID(opts *bind.CallOpts) ([32]byte, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "activeWalletID") + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// ActiveWalletID is a free data retrieval call binding the contract method 0x160c1730. +// +// Solidity: function activeWalletID() view returns(bytes32) +func (_Bridge *BridgeSession) ActiveWalletID() ([32]byte, error) { + return _Bridge.Contract.ActiveWalletID(&_Bridge.CallOpts) +} + +// ActiveWalletID is a free data retrieval call binding the contract method 0x160c1730. +// +// Solidity: function activeWalletID() view returns(bytes32) +func (_Bridge *BridgeCallerSession) ActiveWalletID() ([32]byte, error) { + return _Bridge.Contract.ActiveWalletID(&_Bridge.CallOpts) +} + // ActiveWalletPubKeyHash is a free data retrieval call binding the contract method 0xded1d24a. // // Solidity: function activeWalletPubKeyHash() view returns(bytes20) @@ -528,6 +559,37 @@ func (_Bridge *BridgeCallerSession) FraudParameters() (struct { return _Bridge.Contract.FraudParameters(&_Bridge.CallOpts) } +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCaller) GetRebateStaking(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "getRebateStaking") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCallerSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + // GetRedemptionWatchtower is a free data retrieval call binding the contract method 0x5f3281ca. // // Solidity: function getRedemptionWatchtower() view returns(address) @@ -998,6 +1060,37 @@ func (_Bridge *BridgeCallerSession) TxProofDifficultyFactor() (*big.Int, error) return _Bridge.Contract.TxProofDifficultyFactor(&_Bridge.CallOpts) } +// WalletID is a free data retrieval call binding the contract method 0x858c14bd. +// +// Solidity: function walletID(bytes20 walletPubKeyHash) pure returns(bytes32) +func (_Bridge *BridgeCaller) WalletID(opts *bind.CallOpts, walletPubKeyHash [20]byte) ([32]byte, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "walletID", walletPubKeyHash) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// WalletID is a free data retrieval call binding the contract method 0x858c14bd. +// +// Solidity: function walletID(bytes20 walletPubKeyHash) pure returns(bytes32) +func (_Bridge *BridgeSession) WalletID(walletPubKeyHash [20]byte) ([32]byte, error) { + return _Bridge.Contract.WalletID(&_Bridge.CallOpts, walletPubKeyHash) +} + +// WalletID is a free data retrieval call binding the contract method 0x858c14bd. +// +// Solidity: function walletID(bytes20 walletPubKeyHash) pure returns(bytes32) +func (_Bridge *BridgeCallerSession) WalletID(walletPubKeyHash [20]byte) ([32]byte, error) { + return _Bridge.Contract.WalletID(&_Bridge.CallOpts, walletPubKeyHash) +} + // WalletParameters is a free data retrieval call binding the contract method 0x61ccf97a. // // Solidity: function walletParameters() view returns(uint32 walletCreationPeriod, uint64 walletCreationMinBtcBalance, uint64 walletCreationMaxBtcBalance, uint64 walletClosureMinBtcBalance, uint32 walletMaxAge, uint64 walletMaxBtcTransfer, uint32 walletClosingPeriod) @@ -1068,6 +1161,37 @@ func (_Bridge *BridgeCallerSession) WalletParameters() (struct { return _Bridge.Contract.WalletParameters(&_Bridge.CallOpts) } +// WalletPubKeyHashForWalletID is a free data retrieval call binding the contract method 0x9a4f2ea9. +// +// Solidity: function walletPubKeyHashForWalletID(bytes32 walletId) view returns(bytes20) +func (_Bridge *BridgeCaller) WalletPubKeyHashForWalletID(opts *bind.CallOpts, walletId [32]byte) ([20]byte, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "walletPubKeyHashForWalletID", walletId) + + if err != nil { + return *new([20]byte), err + } + + out0 := *abi.ConvertType(out[0], new([20]byte)).(*[20]byte) + + return out0, err + +} + +// WalletPubKeyHashForWalletID is a free data retrieval call binding the contract method 0x9a4f2ea9. +// +// Solidity: function walletPubKeyHashForWalletID(bytes32 walletId) view returns(bytes20) +func (_Bridge *BridgeSession) WalletPubKeyHashForWalletID(walletId [32]byte) ([20]byte, error) { + return _Bridge.Contract.WalletPubKeyHashForWalletID(&_Bridge.CallOpts, walletId) +} + +// WalletPubKeyHashForWalletID is a free data retrieval call binding the contract method 0x9a4f2ea9. +// +// Solidity: function walletPubKeyHashForWalletID(bytes32 walletId) view returns(bytes20) +func (_Bridge *BridgeCallerSession) WalletPubKeyHashForWalletID(walletId [32]byte) ([20]byte, error) { + return _Bridge.Contract.WalletPubKeyHashForWalletID(&_Bridge.CallOpts, walletId) +} + // Wallets is a free data retrieval call binding the contract method 0xe65e19d5. // // Solidity: function wallets(bytes20 walletPubKeyHash) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) @@ -1099,6 +1223,37 @@ func (_Bridge *BridgeCallerSession) Wallets(walletPubKeyHash [20]byte) (WalletsW return _Bridge.Contract.Wallets(&_Bridge.CallOpts, walletPubKeyHash) } +// WalletsByWalletID is a free data retrieval call binding the contract method 0xa9b2f9a3. +// +// Solidity: function walletsByWalletID(bytes32 walletId) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) +func (_Bridge *BridgeCaller) WalletsByWalletID(opts *bind.CallOpts, walletId [32]byte) (WalletsWallet, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "walletsByWalletID", walletId) + + if err != nil { + return *new(WalletsWallet), err + } + + out0 := *abi.ConvertType(out[0], new(WalletsWallet)).(*WalletsWallet) + + return out0, err + +} + +// WalletsByWalletID is a free data retrieval call binding the contract method 0xa9b2f9a3. +// +// Solidity: function walletsByWalletID(bytes32 walletId) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) +func (_Bridge *BridgeSession) WalletsByWalletID(walletId [32]byte) (WalletsWallet, error) { + return _Bridge.Contract.WalletsByWalletID(&_Bridge.CallOpts, walletId) +} + +// WalletsByWalletID is a free data retrieval call binding the contract method 0xa9b2f9a3. +// +// Solidity: function walletsByWalletID(bytes32 walletId) view returns((bytes32,bytes32,uint64,uint32,uint32,uint32,uint32,uint8,bytes32)) +func (_Bridge *BridgeCallerSession) WalletsByWalletID(walletId [32]byte) (WalletsWallet, error) { + return _Bridge.Contract.WalletsByWalletID(&_Bridge.CallOpts, walletId) +} + // EcdsaWalletCreatedCallback is a paid mutator transaction binding the contract method 0xa8fa0f42. // // Solidity: function __ecdsaWalletCreatedCallback(bytes32 ecdsaWalletID, bytes32 publicKeyX, bytes32 publicKeyY) returns() @@ -1204,6 +1359,27 @@ func (_Bridge *BridgeTransactorSession) Initialize(_bank common.Address, _relay return _Bridge.Contract.Initialize(&_Bridge.TransactOpts, _bank, _relay, _treasury, _ecdsaWalletRegistry, _reimbursementPool, _txProofDifficultyFactor) } +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactor) InitializeV2FixVaultZeroDeposit(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "initializeV2_FixVaultZeroDeposit") +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactorSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + // NotifyFraudChallengeDefeatTimeout is a paid mutator transaction binding the contract method 0x79fc4eb3. // // Solidity: function notifyFraudChallengeDefeatTimeout(bytes walletPublicKey, uint32[] walletMembersIDs, bytes preimageSha256) returns() @@ -1498,6 +1674,27 @@ func (_Bridge *BridgeTransactorSession) RevealDepositWithExtraData(fundingTx Bit return _Bridge.Contract.RevealDepositWithExtraData(&_Bridge.TransactOpts, fundingTx, reveal, extraData) } +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactor) SetRebateStaking(opts *bind.TransactOpts, rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setRebateStaking", rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactorSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + // SetRedemptionWatchtower is a paid mutator transaction binding the contract method 0xbe26ebad. // // Solidity: function setRedemptionWatchtower(address redemptionWatchtower) returns() @@ -2133,6 +2330,151 @@ func (_Bridge *BridgeFilterer) ParseDepositRevealed(log types.Log) (*BridgeDepos return event, nil } +// BridgeDepositVaultFixedIterator is returned from FilterDepositVaultFixed and is used to iterate over the raw logs and unpacked data for DepositVaultFixed events raised by the Bridge contract. +type BridgeDepositVaultFixedIterator struct { + Event *BridgeDepositVaultFixed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeDepositVaultFixedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeDepositVaultFixed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeDepositVaultFixed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeDepositVaultFixedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeDepositVaultFixedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeDepositVaultFixed represents a DepositVaultFixed event raised by the Bridge contract. +type BridgeDepositVaultFixed struct { + DepositKey *big.Int + NewVault common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDepositVaultFixed is a free log retrieval operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) FilterDepositVaultFixed(opts *bind.FilterOpts, depositKey []*big.Int) (*BridgeDepositVaultFixedIterator, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "DepositVaultFixed", depositKeyRule) + if err != nil { + return nil, err + } + return &BridgeDepositVaultFixedIterator{contract: _Bridge.contract, event: "DepositVaultFixed", logs: logs, sub: sub}, nil +} + +// WatchDepositVaultFixed is a free log subscription operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) WatchDepositVaultFixed(opts *bind.WatchOpts, sink chan<- *BridgeDepositVaultFixed, depositKey []*big.Int) (event.Subscription, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "DepositVaultFixed", depositKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDepositVaultFixed is a log parse operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) ParseDepositVaultFixed(log types.Log) (*BridgeDepositVaultFixed, error) { + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeDepositsSweptIterator is returned from FilterDepositsSwept and is used to iterate over the raw logs and unpacked data for DepositsSwept events raised by the Bridge contract. type BridgeDepositsSweptIterator struct { Event *BridgeDepositsSwept // Event containing the contract specifics and raw log @@ -4423,6 +4765,168 @@ func (_Bridge *BridgeFilterer) ParseNewWalletRegistered(log types.Log) (*BridgeN return event, nil } +// BridgeNewWalletRegisteredV2Iterator is returned from FilterNewWalletRegisteredV2 and is used to iterate over the raw logs and unpacked data for NewWalletRegisteredV2 events raised by the Bridge contract. +type BridgeNewWalletRegisteredV2Iterator struct { + Event *BridgeNewWalletRegisteredV2 // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeNewWalletRegisteredV2Iterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRegisteredV2) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeNewWalletRegisteredV2) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeNewWalletRegisteredV2Iterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeNewWalletRegisteredV2Iterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeNewWalletRegisteredV2 represents a NewWalletRegisteredV2 event raised by the Bridge contract. +type BridgeNewWalletRegisteredV2 struct { + WalletID [32]byte + EcdsaWalletID [32]byte + WalletPubKeyHash [20]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterNewWalletRegisteredV2 is a free log retrieval operation binding the contract event 0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b. +// +// Solidity: event NewWalletRegisteredV2(bytes32 indexed walletID, bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) FilterNewWalletRegisteredV2(opts *bind.FilterOpts, walletID [][32]byte, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (*BridgeNewWalletRegisteredV2Iterator, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var ecdsaWalletIDRule []interface{} + for _, ecdsaWalletIDItem := range ecdsaWalletID { + ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "NewWalletRegisteredV2", walletIDRule, ecdsaWalletIDRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &BridgeNewWalletRegisteredV2Iterator{contract: _Bridge.contract, event: "NewWalletRegisteredV2", logs: logs, sub: sub}, nil +} + +// WatchNewWalletRegisteredV2 is a free log subscription operation binding the contract event 0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b. +// +// Solidity: event NewWalletRegisteredV2(bytes32 indexed walletID, bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) WatchNewWalletRegisteredV2(opts *bind.WatchOpts, sink chan<- *BridgeNewWalletRegisteredV2, walletID [][32]byte, ecdsaWalletID [][32]byte, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var walletIDRule []interface{} + for _, walletIDItem := range walletID { + walletIDRule = append(walletIDRule, walletIDItem) + } + var ecdsaWalletIDRule []interface{} + for _, ecdsaWalletIDItem := range ecdsaWalletID { + ecdsaWalletIDRule = append(ecdsaWalletIDRule, ecdsaWalletIDItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "NewWalletRegisteredV2", walletIDRule, ecdsaWalletIDRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeNewWalletRegisteredV2) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRegisteredV2", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseNewWalletRegisteredV2 is a log parse operation binding the contract event 0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b. +// +// Solidity: event NewWalletRegisteredV2(bytes32 indexed walletID, bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash) +func (_Bridge *BridgeFilterer) ParseNewWalletRegisteredV2(log types.Log) (*BridgeNewWalletRegisteredV2, error) { + event := new(BridgeNewWalletRegisteredV2) + if err := _Bridge.contract.UnpackLog(event, "NewWalletRegisteredV2", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeNewWalletRequestedIterator is returned from FilterNewWalletRequested and is used to iterate over the raw logs and unpacked data for NewWalletRequested events raised by the Bridge contract. type BridgeNewWalletRequestedIterator struct { Event *BridgeNewWalletRequested // Event containing the contract specifics and raw log @@ -4556,6 +5060,140 @@ func (_Bridge *BridgeFilterer) ParseNewWalletRequested(log types.Log) (*BridgeNe return event, nil } +// BridgeRebateStakingSetIterator is returned from FilterRebateStakingSet and is used to iterate over the raw logs and unpacked data for RebateStakingSet events raised by the Bridge contract. +type BridgeRebateStakingSetIterator struct { + Event *BridgeRebateStakingSet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeRebateStakingSetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeRebateStakingSetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeRebateStakingSetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeRebateStakingSet represents a RebateStakingSet event raised by the Bridge contract. +type BridgeRebateStakingSet struct { + RebateStaking common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRebateStakingSet is a free log retrieval operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) FilterRebateStakingSet(opts *bind.FilterOpts) (*BridgeRebateStakingSetIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "RebateStakingSet") + if err != nil { + return nil, err + } + return &BridgeRebateStakingSetIterator{contract: _Bridge.contract, event: "RebateStakingSet", logs: logs, sub: sub}, nil +} + +// WatchRebateStakingSet is a free log subscription operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) WatchRebateStakingSet(opts *bind.WatchOpts, sink chan<- *BridgeRebateStakingSet) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "RebateStakingSet") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRebateStakingSet is a log parse operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) ParseRebateStakingSet(log types.Log) (*BridgeRebateStakingSet, error) { + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeRedemptionParametersUpdatedIterator is returned from FilterRedemptionParametersUpdated and is used to iterate over the raw logs and unpacked data for RedemptionParametersUpdated events raised by the Bridge contract. type BridgeRedemptionParametersUpdatedIterator struct { Event *BridgeRedemptionParametersUpdated // Event containing the contract specifics and raw log diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go index f7a5944669..5af214163a 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go @@ -52,12 +52,14 @@ func init() { } BridgeCommand.AddCommand( + bActiveWalletIDCommand(), bActiveWalletPubKeyHashCommand(), bContractReferencesCommand(), bDepositParametersCommand(), bDepositsCommand(), bFraudChallengesCommand(), bFraudParametersCommand(), + bGetRebateStakingCommand(), bGetRedemptionWatchtowerCommand(), bGovernanceCommand(), bIsVaultTrustedCommand(), @@ -70,13 +72,17 @@ func init() { bTimedOutRedemptionsCommand(), bTreasuryCommand(), bTxProofDifficultyFactorCommand(), + bWalletIDCommand(), bWalletParametersCommand(), + bWalletPubKeyHashForWalletIDCommand(), bWalletsCommand(), + bWalletsByWalletIDCommand(), bDefeatFraudChallengeCommand(), bDefeatFraudChallengeWithHeartbeatCommand(), bEcdsaWalletCreatedCallbackCommand(), bEcdsaWalletHeartbeatFailedCallbackCommand(), bInitializeCommand(), + bInitializeV2FixVaultZeroDepositCommand(), bNotifyMovingFundsBelowDustCommand(), bNotifyRedemptionVetoCommand(), bNotifyWalletCloseableCommand(), @@ -87,6 +93,7 @@ func init() { bResetMovingFundsTimeoutCommand(), bRevealDepositCommand(), bRevealDepositWithExtraDataCommand(), + bSetRebateStakingCommand(), bSetRedemptionWatchtowerCommand(), bSetSpvMaintainerStatusCommand(), bSetVaultStatusCommand(), @@ -109,6 +116,40 @@ func init() { /// ------------------- Const methods ------------------- +func bActiveWalletIDCommand() *cobra.Command { + c := &cobra.Command{ + Use: "active-wallet-i-d", + Short: "Calls the view method activeWalletID on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bActiveWalletID, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bActiveWalletID(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.ActiveWalletIDAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bActiveWalletPubKeyHashCommand() *cobra.Command { c := &cobra.Command{ Use: "active-wallet-pub-key-hash", @@ -331,6 +372,40 @@ func bFraudParameters(c *cobra.Command, args []string) error { return nil } +func bGetRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "get-rebate-staking", + Short: "Calls the view method getRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bGetRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bGetRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.GetRebateStakingAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bGetRedemptionWatchtowerCommand() *cobra.Command { c := &cobra.Command{ Use: "get-redemption-watchtower", @@ -784,6 +859,49 @@ func bTxProofDifficultyFactor(c *cobra.Command, args []string) error { return nil } +func bWalletIDCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-i-d [arg_walletPubKeyHash]", + Short: "Calls the pure method walletID on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bWalletID, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bWalletID(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletIDAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bWalletParametersCommand() *cobra.Command { c := &cobra.Command{ Use: "wallet-parameters", @@ -818,6 +936,49 @@ func bWalletParameters(c *cobra.Command, args []string) error { return nil } +func bWalletPubKeyHashForWalletIDCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-pub-key-hash-for-wallet-i-d [arg_walletId]", + Short: "Calls the view method walletPubKeyHashForWalletID on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bWalletPubKeyHashForWalletID, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bWalletPubKeyHashForWalletID(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_walletId, err := decode.ParseBytes32(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletId, a bytes32, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletPubKeyHashForWalletIDAtBlock( + arg_walletId, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bWalletsCommand() *cobra.Command { c := &cobra.Command{ Use: "wallets [arg_walletPubKeyHash]", @@ -861,6 +1022,49 @@ func bWallets(c *cobra.Command, args []string) error { return nil } +func bWalletsByWalletIDCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallets-by-wallet-i-d [arg_walletId]", + Short: "Calls the view method walletsByWalletID on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bWalletsByWalletID, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bWalletsByWalletID(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_walletId, err := decode.ParseBytes32(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletId, a bytes32, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletsByWalletIDAtBlock( + arg_walletId, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + /// ------------------- Non-const methods ------------------- func bDefeatFraudChallengeCommand() *cobra.Command { @@ -1296,6 +1500,60 @@ func bInitialize(c *cobra.Command, args []string) error { return nil } +func bInitializeV2FixVaultZeroDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "initialize-v2-fix-vault-zero-deposit", + Short: "Calls the nonpayable method initializeV2FixVaultZeroDeposit on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bInitializeV2FixVaultZeroDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bInitializeV2FixVaultZeroDeposit(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.InitializeV2FixVaultZeroDeposit() + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallInitializeV2FixVaultZeroDeposit( + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bNotifyMovingFundsBelowDustCommand() *cobra.Command { c := &cobra.Command{ Use: "notify-moving-funds-below-dust [arg_walletPubKeyHash] [arg_mainUtxo_json]", @@ -2026,6 +2284,71 @@ func bRevealDepositWithExtraData(c *cobra.Command, args []string) error { return nil } +func bSetRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-rebate-staking [arg_rebateStaking]", + Short: "Calls the nonpayable method setRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_rebateStaking, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_rebateStaking, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetRebateStaking( + arg_rebateStaking, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetRebateStaking( + arg_rebateStaking, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bSetRedemptionWatchtowerCommand() *cobra.Command { c := &cobra.Command{ Use: "set-redemption-watchtower [arg_redemptionWatchtower]", diff --git a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go index ae73c92607..c0b3348064 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go @@ -914,6 +914,130 @@ func (b *Bridge) InitializeGasEstimate( return result, err } +// Transaction submission. +func (b *Bridge) InitializeV2FixVaultZeroDeposit( + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction initializeV2FixVaultZeroDeposit", + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( + transactorOptions, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV2FixVaultZeroDeposit", + ) + } + + bLogger.Infof( + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( + newTransactorOptions, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV2FixVaultZeroDeposit", + ) + } + + bLogger.Infof( + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallInitializeV2FixVaultZeroDeposit( + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "initializeV2FixVaultZeroDeposit", + &result, + ) + + return err +} + +func (b *Bridge) InitializeV2FixVaultZeroDepositGasEstimate() (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "initializeV2FixVaultZeroDeposit", + b.contractABI, + b.transactor, + ) + + return result, err +} + // Transaction submission. func (b *Bridge) NotifyFraudChallengeDefeatTimeout( arg_walletPublicKey []byte, @@ -3026,6 +3150,144 @@ func (b *Bridge) RevealDepositWithExtraDataGasEstimate( return result, err } +// Transaction submission. +func (b *Bridge) SetRebateStaking( + arg_rebateStaking common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction setRebateStaking", + " params: ", + fmt.Sprint( + arg_rebateStaking, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.SetRebateStaking( + transactorOptions, + arg_rebateStaking, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setRebateStaking", + arg_rebateStaking, + ) + } + + bLogger.Infof( + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.SetRebateStaking( + newTransactorOptions, + arg_rebateStaking, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setRebateStaking", + arg_rebateStaking, + ) + } + + bLogger.Infof( + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallSetRebateStaking( + arg_rebateStaking common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "setRebateStaking", + &result, + arg_rebateStaking, + ) + + return err +} + +func (b *Bridge) SetRebateStakingGasEstimate( + arg_rebateStaking common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "setRebateStaking", + b.contractABI, + b.transactor, + arg_rebateStaking, + ) + + return result, err +} + // Transaction submission. func (b *Bridge) SetRedemptionWatchtower( arg_redemptionWatchtower common.Address, @@ -5706,8 +5968,8 @@ func (b *Bridge) UpdateWalletParametersGasEstimate( // ----- Const Methods ------ -func (b *Bridge) ActiveWalletPubKeyHash() ([20]byte, error) { - result, err := b.contract.ActiveWalletPubKeyHash( +func (b *Bridge) ActiveWalletID() ([32]byte, error) { + result, err := b.contract.ActiveWalletID( b.callerOptions, ) @@ -5716,17 +5978,17 @@ func (b *Bridge) ActiveWalletPubKeyHash() ([20]byte, error) { err, b.callerOptions.From, nil, - "activeWalletPubKeyHash", + "activeWalletID", ) } return result, err } -func (b *Bridge) ActiveWalletPubKeyHashAtBlock( +func (b *Bridge) ActiveWalletIDAtBlock( blockNumber *big.Int, -) ([20]byte, error) { - var result [20]byte +) ([32]byte, error) { + var result [32]byte err := chainutil.CallAtBlock( b.callerOptions.From, @@ -5736,22 +5998,15 @@ func (b *Bridge) ActiveWalletPubKeyHashAtBlock( b.caller, b.errorResolver, b.contractAddress, - "activeWalletPubKeyHash", + "activeWalletID", &result, ) return result, err } -type contractReferences struct { - Bank common.Address - Relay common.Address - EcdsaWalletRegistry common.Address - ReimbursementPool common.Address -} - -func (b *Bridge) ContractReferences() (contractReferences, error) { - result, err := b.contract.ContractReferences( +func (b *Bridge) ActiveWalletPubKeyHash() ([20]byte, error) { + result, err := b.contract.ActiveWalletPubKeyHash( b.callerOptions, ) @@ -5760,17 +6015,17 @@ func (b *Bridge) ContractReferences() (contractReferences, error) { err, b.callerOptions.From, nil, - "contractReferences", + "activeWalletPubKeyHash", ) } return result, err } -func (b *Bridge) ContractReferencesAtBlock( +func (b *Bridge) ActiveWalletPubKeyHashAtBlock( blockNumber *big.Int, -) (contractReferences, error) { - var result contractReferences +) ([20]byte, error) { + var result [20]byte err := chainutil.CallAtBlock( b.callerOptions.From, @@ -5780,7 +6035,51 @@ func (b *Bridge) ContractReferencesAtBlock( b.caller, b.errorResolver, b.contractAddress, - "contractReferences", + "activeWalletPubKeyHash", + &result, + ) + + return result, err +} + +type contractReferences struct { + Bank common.Address + Relay common.Address + EcdsaWalletRegistry common.Address + ReimbursementPool common.Address +} + +func (b *Bridge) ContractReferences() (contractReferences, error) { + result, err := b.contract.ContractReferences( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "contractReferences", + ) + } + + return result, err +} + +func (b *Bridge) ContractReferencesAtBlock( + blockNumber *big.Int, +) (contractReferences, error) { + var result contractReferences + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "contractReferences", &result, ) @@ -5961,6 +6260,43 @@ func (b *Bridge) FraudParametersAtBlock( return result, err } +func (b *Bridge) GetRebateStaking() (common.Address, error) { + result, err := b.contract.GetRebateStaking( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "getRebateStaking", + ) + } + + return result, err +} + +func (b *Bridge) GetRebateStakingAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "getRebateStaking", + &result, + ) + + return result, err +} + func (b *Bridge) GetRedemptionWatchtower() (common.Address, error) { result, err := b.contract.GetRedemptionWatchtower( b.callerOptions, @@ -6459,6 +6795,49 @@ func (b *Bridge) TxProofDifficultyFactorAtBlock( return result, err } +func (b *Bridge) WalletID( + arg_walletPubKeyHash [20]byte, +) ([32]byte, error) { + result, err := b.contract.WalletID( + b.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "walletID", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (b *Bridge) WalletIDAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) ([32]byte, error) { + var result [32]byte + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "walletID", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + type walletParameters struct { WalletCreationPeriod uint32 WalletCreationMinBtcBalance uint64 @@ -6506,6 +6885,49 @@ func (b *Bridge) WalletParametersAtBlock( return result, err } +func (b *Bridge) WalletPubKeyHashForWalletID( + arg_walletId [32]byte, +) ([20]byte, error) { + result, err := b.contract.WalletPubKeyHashForWalletID( + b.callerOptions, + arg_walletId, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "walletPubKeyHashForWalletID", + arg_walletId, + ) + } + + return result, err +} + +func (b *Bridge) WalletPubKeyHashForWalletIDAtBlock( + arg_walletId [32]byte, + blockNumber *big.Int, +) ([20]byte, error) { + var result [20]byte + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "walletPubKeyHashForWalletID", + &result, + arg_walletId, + ) + + return result, err +} + func (b *Bridge) Wallets( arg_walletPubKeyHash [20]byte, ) (abi.WalletsWallet, error) { @@ -6549,6 +6971,49 @@ func (b *Bridge) WalletsAtBlock( return result, err } +func (b *Bridge) WalletsByWalletID( + arg_walletId [32]byte, +) (abi.WalletsWallet, error) { + result, err := b.contract.WalletsByWalletID( + b.callerOptions, + arg_walletId, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "walletsByWalletID", + arg_walletId, + ) + } + + return result, err +} + +func (b *Bridge) WalletsByWalletIDAtBlock( + arg_walletId [32]byte, + blockNumber *big.Int, +) (abi.WalletsWallet, error) { + var result abi.WalletsWallet + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "walletsByWalletID", + &result, + arg_walletId, + ) + + return result, err +} + // ------ Events ------- func (b *Bridge) DepositParametersUpdatedEvent( @@ -6949,9 +7414,10 @@ func (b *Bridge) PastDepositRevealedEvents( return events, nil } -func (b *Bridge) DepositsSweptEvent( +func (b *Bridge) DepositVaultFixedEvent( opts *ethereum.SubscribeOpts, -) *BDepositsSweptSubscription { + depositKeyFilter []*big.Int, +) *BDepositVaultFixedSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -6962,27 +7428,29 @@ func (b *Bridge) DepositsSweptEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BDepositsSweptSubscription{ + return &BDepositVaultFixedSubscription{ b, opts, + depositKeyFilter, } } -type BDepositsSweptSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts +type BDepositVaultFixedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + depositKeyFilter []*big.Int } -type bridgeDepositsSweptFunc func( - WalletPubKeyHash [20]byte, - SweepTxHash [32]byte, +type bridgeDepositVaultFixedFunc func( + DepositKey *big.Int, + NewVault common.Address, blockNumber uint64, ) -func (dss *BDepositsSweptSubscription) OnEvent( - handler bridgeDepositsSweptFunc, +func (dvfs *BDepositVaultFixedSubscription) OnEvent( + handler bridgeDepositVaultFixedFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeDepositsSwept) + eventChan := make(chan *abi.BridgeDepositVaultFixed) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -6992,50 +7460,51 @@ func (dss *BDepositsSweptSubscription) OnEvent( return case event := <-eventChan: handler( - event.WalletPubKeyHash, - event.SweepTxHash, + event.DepositKey, + event.NewVault, event.Raw.BlockNumber, ) } } }() - sub := dss.Pipe(eventChan) + sub := dvfs.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (dss *BDepositsSweptSubscription) Pipe( - sink chan *abi.BridgeDepositsSwept, +func (dvfs *BDepositVaultFixedSubscription) Pipe( + sink chan *abi.BridgeDepositVaultFixed, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(dss.opts.Tick) + ticker := time.NewTicker(dvfs.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := dss.contract.blockCounter.CurrentBlock() + lastBlock, err := dvfs.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - dss.opts.PastBlocks + fromBlock := lastBlock - dvfs.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past DepositsSwept events "+ + "subscription monitoring fetching past DepositVaultFixed events "+ "starting from block [%v]", fromBlock, ) - events, err := dss.contract.PastDepositsSweptEvents( + events, err := dvfs.contract.PastDepositVaultFixedEvents( fromBlock, nil, + dvfs.depositKeyFilter, ) if err != nil { bLogger.Errorf( @@ -7045,7 +7514,7 @@ func (dss *BDepositsSweptSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past DepositsSwept events", + "subscription monitoring fetched [%v] past DepositVaultFixed events", len(events), ) @@ -7056,8 +7525,9 @@ func (dss *BDepositsSweptSubscription) Pipe( } }() - sub := dss.contract.watchDepositsSwept( + sub := dvfs.contract.watchDepositVaultFixed( sink, + dvfs.depositKeyFilter, ) return subscription.NewEventSubscription(func() { @@ -7066,19 +7536,21 @@ func (dss *BDepositsSweptSubscription) Pipe( }) } -func (b *Bridge) watchDepositsSwept( - sink chan *abi.BridgeDepositsSwept, +func (b *Bridge) watchDepositVaultFixed( + sink chan *abi.BridgeDepositVaultFixed, + depositKeyFilter []*big.Int, ) event.Subscription { subscribeFn := func(ctx context.Context) (event.Subscription, error) { - return b.contract.WatchDepositsSwept( + return b.contract.WatchDepositVaultFixed( &bind.WatchOpts{Context: ctx}, sink, + depositKeyFilter, ) } thresholdViolatedFn := func(elapsed time.Duration) { bLogger.Warnf( - "subscription to event DepositsSwept had to be "+ + "subscription to event DepositVaultFixed had to be "+ "retried [%s] since the last attempt; please inspect "+ "host chain connectivity", elapsed, @@ -7087,7 +7559,7 @@ func (b *Bridge) watchDepositsSwept( subscriptionFailedFn := func(err error) { bLogger.Errorf( - "subscription to event DepositsSwept failed "+ + "subscription to event DepositVaultFixed failed "+ "with error: [%v]; resubscription attempt will be "+ "performed", err, @@ -7103,24 +7575,26 @@ func (b *Bridge) watchDepositsSwept( ) } -func (b *Bridge) PastDepositsSweptEvents( +func (b *Bridge) PastDepositVaultFixedEvents( startBlock uint64, endBlock *uint64, -) ([]*abi.BridgeDepositsSwept, error) { - iterator, err := b.contract.FilterDepositsSwept( + depositKeyFilter []*big.Int, +) ([]*abi.BridgeDepositVaultFixed, error) { + iterator, err := b.contract.FilterDepositVaultFixed( &bind.FilterOpts{ Start: startBlock, End: endBlock, }, + depositKeyFilter, ) if err != nil { return nil, fmt.Errorf( - "error retrieving past DepositsSwept events: [%v]", + "error retrieving past DepositVaultFixed events: [%v]", err, ) } - events := make([]*abi.BridgeDepositsSwept, 0) + events := make([]*abi.BridgeDepositVaultFixed, 0) for iterator.Next() { event := iterator.Event @@ -7130,10 +7604,9 @@ func (b *Bridge) PastDepositsSweptEvents( return events, nil } -func (b *Bridge) FraudChallengeDefeatTimedOutEvent( +func (b *Bridge) DepositsSweptEvent( opts *ethereum.SubscribeOpts, - walletPubKeyHashFilter [][20]byte, -) *BFraudChallengeDefeatTimedOutSubscription { +) *BDepositsSweptSubscription { if opts == nil { opts = new(ethereum.SubscribeOpts) } @@ -7144,29 +7617,27 @@ func (b *Bridge) FraudChallengeDefeatTimedOutEvent( opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks } - return &BFraudChallengeDefeatTimedOutSubscription{ + return &BDepositsSweptSubscription{ b, opts, - walletPubKeyHashFilter, } } -type BFraudChallengeDefeatTimedOutSubscription struct { - contract *Bridge - opts *ethereum.SubscribeOpts - walletPubKeyHashFilter [][20]byte +type BDepositsSweptSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts } -type bridgeFraudChallengeDefeatTimedOutFunc func( +type bridgeDepositsSweptFunc func( WalletPubKeyHash [20]byte, - Sighash [32]byte, + SweepTxHash [32]byte, blockNumber uint64, ) -func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) OnEvent( - handler bridgeFraudChallengeDefeatTimedOutFunc, +func (dss *BDepositsSweptSubscription) OnEvent( + handler bridgeDepositsSweptFunc, ) subscription.EventSubscription { - eventChan := make(chan *abi.BridgeFraudChallengeDefeatTimedOut) + eventChan := make(chan *abi.BridgeDepositsSwept) ctx, cancelCtx := context.WithCancel(context.Background()) go func() { @@ -7177,50 +7648,49 @@ func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) OnEvent( case event := <-eventChan: handler( event.WalletPubKeyHash, - event.Sighash, + event.SweepTxHash, event.Raw.BlockNumber, ) } } }() - sub := fcdtos.Pipe(eventChan) + sub := dss.Pipe(eventChan) return subscription.NewEventSubscription(func() { sub.Unsubscribe() cancelCtx() }) } -func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) Pipe( - sink chan *abi.BridgeFraudChallengeDefeatTimedOut, +func (dss *BDepositsSweptSubscription) Pipe( + sink chan *abi.BridgeDepositsSwept, ) subscription.EventSubscription { ctx, cancelCtx := context.WithCancel(context.Background()) go func() { - ticker := time.NewTicker(fcdtos.opts.Tick) + ticker := time.NewTicker(dss.opts.Tick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: - lastBlock, err := fcdtos.contract.blockCounter.CurrentBlock() + lastBlock, err := dss.contract.blockCounter.CurrentBlock() if err != nil { bLogger.Errorf( "subscription failed to pull events: [%v]", err, ) } - fromBlock := lastBlock - fcdtos.opts.PastBlocks + fromBlock := lastBlock - dss.opts.PastBlocks bLogger.Infof( - "subscription monitoring fetching past FraudChallengeDefeatTimedOut events "+ + "subscription monitoring fetching past DepositsSwept events "+ "starting from block [%v]", fromBlock, ) - events, err := fcdtos.contract.PastFraudChallengeDefeatTimedOutEvents( + events, err := dss.contract.PastDepositsSweptEvents( fromBlock, nil, - fcdtos.walletPubKeyHashFilter, ) if err != nil { bLogger.Errorf( @@ -7230,7 +7700,192 @@ func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) Pipe( continue } bLogger.Infof( - "subscription monitoring fetched [%v] past FraudChallengeDefeatTimedOut events", + "subscription monitoring fetched [%v] past DepositsSwept events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := dss.contract.watchDepositsSwept( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchDepositsSwept( + sink chan *abi.BridgeDepositsSwept, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchDepositsSwept( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event DepositsSwept had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event DepositsSwept failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastDepositsSweptEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeDepositsSwept, error) { + iterator, err := b.contract.FilterDepositsSwept( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past DepositsSwept events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeDepositsSwept, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) FraudChallengeDefeatTimedOutEvent( + opts *ethereum.SubscribeOpts, + walletPubKeyHashFilter [][20]byte, +) *BFraudChallengeDefeatTimedOutSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BFraudChallengeDefeatTimedOutSubscription{ + b, + opts, + walletPubKeyHashFilter, + } +} + +type BFraudChallengeDefeatTimedOutSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + walletPubKeyHashFilter [][20]byte +} + +type bridgeFraudChallengeDefeatTimedOutFunc func( + WalletPubKeyHash [20]byte, + Sighash [32]byte, + blockNumber uint64, +) + +func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) OnEvent( + handler bridgeFraudChallengeDefeatTimedOutFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeFraudChallengeDefeatTimedOut) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.WalletPubKeyHash, + event.Sighash, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := fcdtos.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (fcdtos *BFraudChallengeDefeatTimedOutSubscription) Pipe( + sink chan *abi.BridgeFraudChallengeDefeatTimedOut, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(fcdtos.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := fcdtos.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - fcdtos.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past FraudChallengeDefeatTimedOut events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := fcdtos.contract.PastFraudChallengeDefeatTimedOutEvents( + fromBlock, + nil, + fcdtos.walletPubKeyHashFilter, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past FraudChallengeDefeatTimedOut events", len(events), ) @@ -9977,6 +10632,216 @@ func (b *Bridge) PastNewWalletRegisteredEvents( return events, nil } +func (b *Bridge) NewWalletRegisteredV2Event( + opts *ethereum.SubscribeOpts, + walletIDFilter [][32]byte, + ecdsaWalletIDFilter [][32]byte, + walletPubKeyHashFilter [][20]byte, +) *BNewWalletRegisteredV2Subscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BNewWalletRegisteredV2Subscription{ + b, + opts, + walletIDFilter, + ecdsaWalletIDFilter, + walletPubKeyHashFilter, + } +} + +type BNewWalletRegisteredV2Subscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + walletIDFilter [][32]byte + ecdsaWalletIDFilter [][32]byte + walletPubKeyHashFilter [][20]byte +} + +type bridgeNewWalletRegisteredV2Func func( + WalletID [32]byte, + EcdsaWalletID [32]byte, + WalletPubKeyHash [20]byte, + blockNumber uint64, +) + +func (nwrvs *BNewWalletRegisteredV2Subscription) OnEvent( + handler bridgeNewWalletRegisteredV2Func, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeNewWalletRegisteredV2) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.WalletID, + event.EcdsaWalletID, + event.WalletPubKeyHash, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := nwrvs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (nwrvs *BNewWalletRegisteredV2Subscription) Pipe( + sink chan *abi.BridgeNewWalletRegisteredV2, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(nwrvs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := nwrvs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - nwrvs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past NewWalletRegisteredV2 events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := nwrvs.contract.PastNewWalletRegisteredV2Events( + fromBlock, + nil, + nwrvs.walletIDFilter, + nwrvs.ecdsaWalletIDFilter, + nwrvs.walletPubKeyHashFilter, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past NewWalletRegisteredV2 events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := nwrvs.contract.watchNewWalletRegisteredV2( + sink, + nwrvs.walletIDFilter, + nwrvs.ecdsaWalletIDFilter, + nwrvs.walletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchNewWalletRegisteredV2( + sink chan *abi.BridgeNewWalletRegisteredV2, + walletIDFilter [][32]byte, + ecdsaWalletIDFilter [][32]byte, + walletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchNewWalletRegisteredV2( + &bind.WatchOpts{Context: ctx}, + sink, + walletIDFilter, + ecdsaWalletIDFilter, + walletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event NewWalletRegisteredV2 had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event NewWalletRegisteredV2 failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletIDFilter [][32]byte, + ecdsaWalletIDFilter [][32]byte, + walletPubKeyHashFilter [][20]byte, +) ([]*abi.BridgeNewWalletRegisteredV2, error) { + iterator, err := b.contract.FilterNewWalletRegisteredV2( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + walletIDFilter, + ecdsaWalletIDFilter, + walletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past NewWalletRegisteredV2 events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeNewWalletRegisteredV2, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + func (b *Bridge) NewWalletRequestedEvent( opts *ethereum.SubscribeOpts, ) *BNewWalletRequestedSubscription { @@ -10154,6 +11019,185 @@ func (b *Bridge) PastNewWalletRequestedEvents( return events, nil } +func (b *Bridge) RebateStakingSetEvent( + opts *ethereum.SubscribeOpts, +) *BRebateStakingSetSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BRebateStakingSetSubscription{ + b, + opts, + } +} + +type BRebateStakingSetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeRebateStakingSetFunc func( + RebateStaking common.Address, + blockNumber uint64, +) + +func (rsss *BRebateStakingSetSubscription) OnEvent( + handler bridgeRebateStakingSetFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeRebateStakingSet) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.RebateStaking, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rsss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rsss *BRebateStakingSetSubscription) Pipe( + sink chan *abi.BridgeRebateStakingSet, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rsss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rsss.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rsss.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past RebateStakingSet events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rsss.contract.PastRebateStakingSetEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past RebateStakingSet events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rsss.contract.watchRebateStakingSet( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchRebateStakingSet( + sink chan *abi.BridgeRebateStakingSet, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchRebateStakingSet( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event RebateStakingSet had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event RebateStakingSet failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastRebateStakingSetEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeRebateStakingSet, error) { + iterator, err := b.contract.FilterRebateStakingSet( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past RebateStakingSet events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeRebateStakingSet, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + func (b *Bridge) RedemptionParametersUpdatedEvent( opts *ethereum.SubscribeOpts, ) *BRedemptionParametersUpdatedSubscription { diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1c9eef1be0..f8b4235b50 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -4,12 +4,17 @@ import ( "bytes" "crypto/ecdsa" "encoding/hex" + "errors" "fmt" "math/big" "reflect" + "strings" "testing" + "github.com/ethereum/go-ethereum/core/types" "github.com/keep-network/keep-core/pkg/bitcoin" + tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + tbtcpkg "github.com/keep-network/keep-core/pkg/tbtc" "github.com/keep-network/keep-core/pkg/chain" @@ -323,6 +328,541 @@ func TestCalculateWalletID(t *testing.T) { testutils.AssertBytesEqual(t, expectedWalletID[:], actualWalletID[:]) } +type pastNewWalletRegisteredV2EventsBridgeMock struct { + pastEvents func( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) +} + +func (m *pastNewWalletRegisteredV2EventsBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, +) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return m.pastEvents( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + ) +} + +type pastNewWalletRegisteredV2EventsAltFieldBridgeMock struct { + pastEvents func( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsAltFieldEvent, error) +} + +type pastNewWalletRegisteredV2EventsAltFieldEvent struct { + WalletID [32]byte + EcdsaWalletID [32]byte + WalletPublicKeyHash [20]byte + Raw types.Log +} + +func (m *pastNewWalletRegisteredV2EventsAltFieldBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, +) ([]*pastNewWalletRegisteredV2EventsAltFieldEvent, error) { + return m.pastEvents( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + ) +} + +type pastNewWalletRegisteredV2EventsMissingRawBridgeMock struct { + pastEvents func( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsMissingRawEvent, error) +} + +type pastNewWalletRegisteredV2EventsMissingRawEvent struct { + WalletID [32]byte + EcdsaWalletID [32]byte + WalletPubKeyHash [20]byte +} + +func (m *pastNewWalletRegisteredV2EventsMissingRawBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, + endBlock *uint64, + walletID [][32]byte, + ecdsaWalletID [][32]byte, + walletPublicKeyHash [][20]byte, +) ([]*pastNewWalletRegisteredV2EventsMissingRawEvent, error) { + return m.pastEvents( + startBlock, + endBlock, + walletID, + ecdsaWalletID, + walletPublicKeyHash, + ) +} + +type pastNewWalletRegisteredV2EventsWrongSignatureBridgeMock struct{} + +func (m *pastNewWalletRegisteredV2EventsWrongSignatureBridgeMock) PastNewWalletRegisteredV2Events( + startBlock uint64, +) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return nil, nil +} + +func TestPastNewWalletRegisteredEvents_UsesV2EventsWhenAvailable(t *testing.T) { + startBlock := uint64(500) + endBlock := uint64(700) + + expectedWalletIDA := [32]byte{0xaa} + expectedWalletIDB := [32]byte{0xbb} + + expectedECDSAWalletIDA := [32]byte{0xa1} + expectedECDSAWalletIDB := [32]byte{0xb1} + + expectedWalletPublicKeyHashA := [20]byte{0x11} + expectedWalletPublicKeyHashB := [20]byte{0x22} + + legacyFallbackCalled := false + + actualEvents, err := pastNewWalletRegisteredEvents( + startBlock, + &endBlock, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + actualStartBlock uint64, + actualEndBlock *uint64, + _ [][32]byte, + _ [][32]byte, + _ [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + if actualStartBlock != startBlock { + t.Fatalf("unexpected start block: [%v]", actualStartBlock) + } + + if actualEndBlock == nil || *actualEndBlock != endBlock { + t.Fatalf("unexpected end block: [%v]", actualEndBlock) + } + + // Provide events out of order to verify post-conversion sort. + return []*tbtcabi.BridgeNewWalletRegisteredV2{ + { + WalletID: expectedWalletIDB, + EcdsaWalletID: expectedECDSAWalletIDB, + WalletPubKeyHash: expectedWalletPublicKeyHashB, + Raw: types.Log{BlockNumber: 650}, + }, + { + WalletID: expectedWalletIDA, + EcdsaWalletID: expectedECDSAWalletIDA, + WalletPubKeyHash: expectedWalletPublicKeyHashA, + Raw: types.Log{BlockNumber: 600}, + }, + }, nil + }, + }, + func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + legacyFallbackCalled = true + return nil, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if legacyFallbackCalled { + t.Fatal("legacy fallback should not be called when v2 events are present") + } + + if len(actualEvents) != 2 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } + + // Expect ascending block order after conversion. + if actualEvents[0].BlockNumber != 600 || actualEvents[1].BlockNumber != 650 { + t.Fatalf( + "unexpected event ordering by block: [%v], [%v]", + actualEvents[0].BlockNumber, + actualEvents[1].BlockNumber, + ) + } + + if actualEvents[0].WalletID != expectedWalletIDA || + actualEvents[1].WalletID != expectedWalletIDB { + t.Fatal("unexpected wallet IDs in converted events") + } +} + +func TestPastNewWalletRegisteredEvents_FallsBackToLegacyWhenV2Empty(t *testing.T) { + expectedECDSAWalletID := [32]byte{0xdd} + expectedWalletPublicKeyHash := [20]byte{0xee} + + legacyFallbackCalled := false + + actualEvents, err := pastNewWalletRegisteredEvents( + 1, + nil, + nil, // no canonical wallet-ID filter -> fallback path enabled + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return []*tbtcabi.BridgeNewWalletRegisteredV2{}, nil + }, + }, + func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + legacyFallbackCalled = true + return []*tbtcabi.BridgeNewWalletRegistered{ + { + EcdsaWalletID: expectedECDSAWalletID, + WalletPubKeyHash: expectedWalletPublicKeyHash, + Raw: types.Log{BlockNumber: 1000}, + }, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if !legacyFallbackCalled { + t.Fatal("legacy fallback should be called when v2 events are empty") + } + + if len(actualEvents) != 1 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } + + expectedWalletID := tbtcpkg.DeriveLegacyWalletID(expectedWalletPublicKeyHash) + if actualEvents[0].WalletID != expectedWalletID { + t.Fatalf( + "unexpected derived legacy wallet ID\nexpected: [%x]\nactual: [%x]", + expectedWalletID, + actualEvents[0].WalletID, + ) + } +} + +func TestPastNewWalletRegisteredEvents_DoesNotFallbackWithWalletIDFilter(t *testing.T) { + legacyFallbackCalled := false + + walletIDFilter := [][32]byte{ + {0x1}, + } + + actualEvents, err := pastNewWalletRegisteredEvents( + 1, + nil, + walletIDFilter, + nil, + nil, + &pastNewWalletRegisteredV2EventsBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*tbtcabi.BridgeNewWalletRegisteredV2, error) { + return []*tbtcabi.BridgeNewWalletRegisteredV2{}, nil + }, + }, + func(uint64, *uint64, [][32]byte, [][20]byte) ([]*tbtcabi.BridgeNewWalletRegistered, error) { + legacyFallbackCalled = true + return nil, nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if legacyFallbackCalled { + t.Fatal("legacy fallback should be skipped when walletID filter is provided") + } + + if len(actualEvents) != 0 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } +} + +func TestPastNewWalletRegisteredV2Events_ReturnsEmptyWhenMethodUnavailable(t *testing.T) { + actualEvents, err := pastNewWalletRegisteredV2Events( + 1, + nil, + nil, + nil, + nil, + struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if len(actualEvents) != 0 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } +} + +func TestPastNewWalletRegisteredV2Events_UsesWalletPublicKeyHashFallbackField(t *testing.T) { + expectedWalletID := [32]byte{0x01} + expectedECDSAWalletID := [32]byte{0x02} + expectedWalletPublicKeyHash := [20]byte{0x03} + + actualEvents, err := pastNewWalletRegisteredV2Events( + 11, + nil, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsAltFieldBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsAltFieldEvent, error) { + return []*pastNewWalletRegisteredV2EventsAltFieldEvent{ + { + WalletID: expectedWalletID, + EcdsaWalletID: expectedECDSAWalletID, + WalletPublicKeyHash: expectedWalletPublicKeyHash, + Raw: types.Log{BlockNumber: 121}, + }, + }, nil + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if len(actualEvents) != 1 { + t.Fatalf("unexpected events count: [%v]", len(actualEvents)) + } + + if actualEvents[0].WalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualEvents[0].WalletPublicKeyHash, + ) + } +} + +func TestPastNewWalletRegisteredV2Events_ReturnsErrorOnCallPanic(t *testing.T) { + _, err := pastNewWalletRegisteredV2Events( + 1, + nil, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsWrongSignatureBridgeMock{}, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "panic calling PastNewWalletRegisteredV2Events") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestPastNewWalletRegisteredV2Events_ReturnsErrorWhenRawMissing(t *testing.T) { + _, err := pastNewWalletRegisteredV2Events( + 1, + nil, + nil, + nil, + nil, + &pastNewWalletRegisteredV2EventsMissingRawBridgeMock{ + pastEvents: func( + uint64, + *uint64, + [][32]byte, + [][32]byte, + [][20]byte, + ) ([]*pastNewWalletRegisteredV2EventsMissingRawEvent, error) { + return []*pastNewWalletRegisteredV2EventsMissingRawEvent{ + { + WalletID: [32]byte{0x05}, + EcdsaWalletID: [32]byte{0x06}, + WalletPubKeyHash: [20]byte{0x07}, + }, + }, nil + }, + }, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "raw event payload") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +type walletPublicKeyHashForWalletIDBridgeMock struct { + resolve func(walletID [32]byte) ([20]byte, error) +} + +func (m *walletPublicKeyHashForWalletIDBridgeMock) WalletPubKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + return m.resolve(walletID) +} + +func TestResolveWalletPublicKeyHashForWalletID(t *testing.T) { + t.Run("returns canonical mapping when non-zero", func(t *testing.T) { + walletID := [32]byte{0x01} + expectedWalletPublicKeyHash := [20]byte{0xaa} + + actualWalletPublicKeyHash, err := resolveWalletPublicKeyHashForWalletID( + walletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func(actualWalletID [32]byte) ([20]byte, error) { + if actualWalletID != walletID { + t.Fatalf("unexpected wallet ID: [%x]", actualWalletID) + } + + return expectedWalletPublicKeyHash, nil + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } + }) + + t.Run("falls back to legacy extraction when canonical lookup errors", func(t *testing.T) { + expectedWalletPublicKeyHash := [20]byte{0xbb} + legacyWalletID := tbtcpkg.DeriveLegacyWalletID(expectedWalletPublicKeyHash) + + actualWalletPublicKeyHash, err := resolveWalletPublicKeyHashForWalletID( + legacyWalletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, errors.New("canonical lookup unavailable") + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } + }) + + t.Run("falls back to legacy extraction when canonical lookup returns zero", func(t *testing.T) { + expectedWalletPublicKeyHash := [20]byte{0xbc} + legacyWalletID := tbtcpkg.DeriveLegacyWalletID(expectedWalletPublicKeyHash) + + actualWalletPublicKeyHash, err := resolveWalletPublicKeyHashForWalletID( + legacyWalletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, nil + }, + }, + ) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } + }) + + t.Run("returns wrapped canonical error for non-legacy IDs", func(t *testing.T) { + walletID := [32]byte{0xff} + canonicalErr := errors.New("rpc failure") + + _, err := resolveWalletPublicKeyHashForWalletID( + walletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, canonicalErr + }, + }, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "cannot resolve wallet public key hash") { + t.Fatalf("unexpected error: [%v]", err) + } + if !strings.Contains(err.Error(), canonicalErr.Error()) { + t.Fatalf("expected canonical error to be wrapped: [%v]", err) + } + }) + + t.Run("returns not found for non-legacy IDs when canonical lookup returns zero", func(t *testing.T) { + walletID := [32]byte{0xfe} + + _, err := resolveWalletPublicKeyHashForWalletID( + walletID, + &walletPublicKeyHashForWalletIDBridgeMock{ + resolve: func([32]byte) ([20]byte, error) { + return [20]byte{}, nil + }, + }, + ) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "wallet public key hash not found") { + t.Fatalf("unexpected error: [%v]", err) + } + }) +} + func TestParseDkgResultValidationOutcome(t *testing.T) { isValid, err := parseDkgResultValidationOutcome( &struct { diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index 219216c22d..3e57f6a801 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -113,6 +113,7 @@ func (pm *PerformanceMetrics) registerAllMetrics() { MetricSigningSuccessTotal, MetricSigningFailedTotal, MetricSigningTimeoutsTotal, + MetricSigningNativeTBTCSignerFallbackTotal, MetricRedemptionExecutionsTotal, MetricRedemptionExecutionsSuccessTotal, MetricRedemptionExecutionsFailedTotal, @@ -170,43 +171,59 @@ func (pm *PerformanceMetrics) registerAllMetrics() { } // Register per-action type wallet metrics - // For each action type, register: total, success_total, failed_total, duration_seconds + // For each action type, register: total, success_total, failed_total, duration_seconds. + // Collect first, then initialize all maps, and only then register observers to + // avoid concurrent map writes while observers are reading. + perActionCounters := []string{} + perActionDurations := []string{} for _, actionType := range GetAllWalletActionTypes() { - actionCounters := []string{ + perActionCounters = append( + perActionCounters, WalletActionMetricName(actionType, "total"), WalletActionMetricName(actionType, "success_total"), WalletActionMetricName(actionType, "failed_total"), - } - for _, name := range actionCounters { - pm.countersMutex.Lock() - pm.counters[name] = &counter{value: 0} - pm.countersMutex.Unlock() - metricName := name // Capture for closure - pm.registry.ObserveApplicationSource( - "performance", - map[string]Source{ - metricName: func() float64 { - pm.countersMutex.RLock() - c, exists := pm.counters[metricName] - pm.countersMutex.RUnlock() - if !exists { - return 0 - } - c.mutex.RLock() - defer c.mutex.RUnlock() - return c.value - }, + ) + perActionDurations = append( + perActionDurations, + WalletActionMetricName(actionType, "duration_seconds"), + ) + } + + pm.countersMutex.Lock() + for _, name := range perActionCounters { + pm.counters[name] = &counter{value: 0} + } + pm.countersMutex.Unlock() + + for _, name := range perActionCounters { + metricName := name // Capture for closure + pm.registry.ObserveApplicationSource( + "performance", + map[string]Source{ + metricName: func() float64 { + pm.countersMutex.RLock() + c, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if !exists { + return 0 + } + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.value }, - ) - } + }, + ) + } - // Register duration metric for this action type - durationName := WalletActionMetricName(actionType, "duration_seconds") - pm.histogramsMutex.Lock() + pm.histogramsMutex.Lock() + for _, durationName := range perActionDurations { pm.histograms[durationName] = &histogram{ buckets: make(map[float64]float64), } - pm.histogramsMutex.Unlock() + } + pm.histogramsMutex.Unlock() + + for _, durationName := range perActionDurations { durationMetricName := durationName // Capture for closure pm.registry.ObserveApplicationSource( "performance", @@ -616,6 +633,9 @@ const ( MetricSigningDurationSeconds = "signing_duration_seconds" MetricSigningAttemptsPerOperation = "signing_attempts_per_operation" MetricSigningTimeoutsTotal = "signing_timeouts_total" + // MetricSigningNativeTBTCSignerFallbackTotal counts the number of times the + // frost_tbtc_signer path fell back to legacy tECDSA execution. + MetricSigningNativeTBTCSignerFallbackTotal = "signing_native_tbtc_signer_fallback_total" // Redemption Metrics MetricRedemptionExecutionsTotal = "redemption_executions_total" diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 75190c8545..e80817da6c 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -327,6 +327,7 @@ func TestMetricsInitialization(t *testing.T) { MetricDKGJoinedTotal, MetricSigningOperationsTotal, MetricSigningSuccessTotal, + MetricSigningNativeTBTCSignerFallbackTotal, } for _, counterName := range counters { diff --git a/pkg/frost/retry/retry.go b/pkg/frost/retry/retry.go new file mode 100644 index 0000000000..798d3bed30 --- /dev/null +++ b/pkg/frost/retry/retry.go @@ -0,0 +1,341 @@ +package retry + +import ( + "fmt" + "math/rand" + "sort" + + "github.com/keep-network/keep-core/pkg/chain" +) + +type byAddress []chain.Address + +func (ba byAddress) Len() int { return len(ba) } +func (ba byAddress) Swap(i, j int) { ba[i], ba[j] = ba[j], ba[i] } +func (ba byAddress) Less(i, j int) bool { return ba[i] < ba[j] } + +func calculateSeatCount(groupMembers []chain.Address) map[chain.Address]uint { + operatorToSeatCount := make(map[chain.Address]uint) + for _, operator := range groupMembers { + operatorToSeatCount[operator]++ + } + return operatorToSeatCount +} + +// EvaluateRetryParticipantsForSigning takes in a slice of `groupMembers` and +// returns a subslice of those same members of length >= +// `retryParticipantsCount` randomly according to the provided `seed` and +// `retryCount`. +// +// This function is intended to be called during a signing protocol after a +// signing event has failed but *not* due to inactivity. Assuming that some of +// the `groupMembers` are sending corrupted information, either on purpose or +// accidentally, we keep trying to find a subset of `groupMembers` that is as +// small as possible, yet still larger than `retryParticipantsCount`. +// +// The `seed` param needs to vary on a per-message basis but must be the same +// seed between all operators for each invocation. This can be the hash of the +// message since cryptographically secure randomness isn't important. +// +// The `retryCount` denotes the number of the given retry, so that should be +// incremented after each attempt while the `seed` stays consistent on a +// per-message basis. +func EvaluateRetryParticipantsForSigning( + groupMembers []chain.Address, + seed int64, + retryCount uint, + retryParticipantsCount uint, +) ([]chain.Address, error) { + if int(retryParticipantsCount) > len(groupMembers) { + return nil, fmt.Errorf( + "asked for too many seats; [%d] seats were requested, but there are only [%d] available", + retryParticipantsCount, + len(groupMembers), + ) + } + operatorToSeatCount := calculateSeatCount(groupMembers) + + // #nosec G404 (insecure random number source (rand)) + // Shuffling operators for retries does not require secure randomness. + rng := rand.New(rand.NewSource(seed + int64(retryCount))) + + operators := make([]chain.Address, len(operatorToSeatCount)) + i := 0 + for operator := range operatorToSeatCount { + operators[i] = operator + i++ + } + sort.Sort(byAddress(operators)) + rng.Shuffle(len(operators), func(i, j int) { + operators[i], operators[j] = operators[j], operators[i] + }) + + seatCount := uint(0) + acceptedOperators := make(map[chain.Address]bool) + for j := 0; seatCount < retryParticipantsCount; j++ { + operator := operators[j] + seatCount += operatorToSeatCount[operator] + acceptedOperators[operator] = true + } + + var seats []chain.Address + for _, operator := range groupMembers { + if acceptedOperators[operator] { + seats = append(seats, operator) + } + } + return seats, nil +} + +// EvaluateRetryParticipantsForKeyGeneration takes in a slice of `groupMembers` +// and returns a subslice of those same members of length >= +// `retryParticipantsCount` randomly according to the provided `seed` and +// `retryCount`. +// +// This function is intended to be called during key generation after a failure +// *not* due to inactivity. Assuming that some of the `groupMembers` are +// sending corrupted information, either on purpose or accidentally, we keep +// trying to find a subset of `groupMembers` that is as large as possible by +// first excluding single operators, then pairs of operators, then triplets of +// operators. We use the `seed` param to generate randomness to shuffle the +// singles/pairs/triplets of operators to exclude and then use the `retryCount` +// param to select which single/pair/triplet to exclude. +// +// The `seed` param needs to vary on a per-message basis but must be the same +// seed between all operators for each invocation. This can be the hash of the +// message since cryptographically secure randomness isn't important. +// +// The `retryCount` denotes the number of the given retry, so that should be +// incremented after each attempt while the `seed` stays consistent on a +// per-message basis. +func EvaluateRetryParticipantsForKeyGeneration( + groupMembers []chain.Address, + seed int64, + retryCount uint, + retryParticipantsCount uint, +) ([]chain.Address, error) { + remainingTries := retryCount + if int(retryParticipantsCount) > len(groupMembers) { + return nil, fmt.Errorf( + "asked for too many seats; [%d] seats were requested, "+ + "but there are only [%d] available", + retryParticipantsCount, + len(groupMembers), + ) + } + operatorToSeatCount := calculateSeatCount(groupMembers) + // #nosec G404 (insecure random number source (rand)) + // Shuffling operators for retries does not require secure randomness. Unlike + // EvaluateRetryParticipantsForSigning above, we only want to use the seed as + // a source of randomness. The `retryCount` is used to select which operators + // to exclude after we shuffle them. + rng := rand.New(rand.NewSource(seed)) + + operators := make([]chain.Address, 0, len(operatorToSeatCount)) + for operator := range operatorToSeatCount { + // Only include the operators that have few enough seats such that if they + // were excluded we still have at least `retryParticipantsCount` seats. + if len(groupMembers)-int(operatorToSeatCount[operator]) >= int(retryParticipantsCount) { + operators = append(operators, operator) + } + } + sort.Sort(byAddress(operators)) + + usedOperators, tries, ok := excludeSingleOperator( + rng, + groupMembers, + int(remainingTries), + operatorToSeatCount, + operators, + ) + if ok { + return usedOperators, nil + } else { + remainingTries -= uint(tries) + } + + usedOperators, tries, ok = excludeOperatorPairs( + rng, + groupMembers, + int(remainingTries), + operatorToSeatCount, + operators, + int(retryParticipantsCount), + ) + if ok { + return usedOperators, nil + } else { + remainingTries -= uint(tries) + } + + usedOperators, tries, ok = excludeOperatorTriplets( + rng, + groupMembers, + int(remainingTries), + operatorToSeatCount, + operators, + int(retryParticipantsCount), + ) + if ok { + return usedOperators, nil + } else { + remainingTries -= uint(tries) + return nil, fmt.Errorf( + "the retry count [%d] was too large to handle; "+ + "tried every single, pair, and triplet, but still needed [%d] more retries", + retryCount, + remainingTries, + ) + } +} + +// excludeSingleOperator randomly excludes all of an operator's seats from a +// given `groupMembers`. It needs a pre-seeded random generator `rng`, and an +// `index`, which is expected to be inferred from a `retryCount`. +// +// It does this by shuffling a list of eligible-for-exclusion operators +// according to `rng`, selecting the operator according to `index`, and then +// filtering that operator out of `groupMembers`. +// +// In the case that `index` is larger than the number of eligible operators, it +// skips shuffling and returns the number of eligible operators, which is +// useful for determining the index of the operator pair to ignore. +func excludeSingleOperator( + rng *rand.Rand, + groupMembers []chain.Address, + index int, + operatorToSeatCount map[chain.Address]uint, + operators []chain.Address, +) ([]chain.Address, int, bool) { + if index < len(operators) { + rng.Shuffle(len(operators), func(i, j int) { + operators[i], operators[j] = operators[j], operators[i] + }) + removedOperator := operators[index] + usedOperators := make([]chain.Address, 0, len(groupMembers)) + for _, operator := range groupMembers { + if operator != removedOperator { + usedOperators = append(usedOperators, operator) + } + } + return usedOperators, 0, true + } else { + return nil, len(operators), false + } +} + +// excludeOperatorPairs randomly excludes all of a pair of operator's seats from a +// given `groupMembers`. It needs a pre-seeded random generator `rng`, and an +// `index`, which is expected to be inferred from a `retryCount`. +// +// It does this by shuffling a list of eligable-for-exclusion operators +// according to `rng`, selecting the operator according to `index`, and then +// filtering that operator pair out of `groupMembers`. +// +// In the case that `index` is larger than the number of eligible operator +// pairs, it skips shuffling and returns the number of eligible operators +// pairs, which is useful for determining the index of the operator triplet to +// ignore. +func excludeOperatorPairs( + rng *rand.Rand, + groupMembers []chain.Address, + index int, + operatorToSeatCount map[chain.Address]uint, + operators []chain.Address, + retryParticipantsCount int, +) ([]chain.Address, int, bool) { + pairIndexes := make([][2]int, 0, len(operators)*len(operators)) + for i := 0; i < len(operators)-1; i++ { + for j := i + 1; j < len(operators); j++ { + leftOperator := operators[i] + rightOperator := operators[j] + + // Only include the operators pairs that have few enough seats such that + // if they were excluded we still have at least `retryParticipantsCount` + // seats. + count := len(groupMembers) - + int(operatorToSeatCount[leftOperator]) - + int(operatorToSeatCount[rightOperator]) + if count >= int(retryParticipantsCount) { + pairIndexes = append(pairIndexes, [2]int{i, j}) + } + } + } + if index < len(pairIndexes) { + rng.Shuffle(len(pairIndexes), func(i, j int) { + pairIndexes[i], pairIndexes[j] = pairIndexes[j], pairIndexes[i] + }) + pair := pairIndexes[index] + leftOperator := operators[pair[0]] + rightOperator := operators[pair[1]] + usedOperators := make([]chain.Address, 0, len(groupMembers)) + for _, operator := range groupMembers { + if operator != leftOperator && operator != rightOperator { + usedOperators = append(usedOperators, operator) + } + } + return usedOperators, 0, true + } else { + return nil, len(pairIndexes), false + } +} + +// excludeOperatorTriplets randomly excludes all of a triplet of operator's seats from a +// given `groupMembers`. It needs a pre-seeded random generator `rng`, and an +// `index`, which is expected to be inferred from a `retryCount`. +// +// It does this by shuffling a list of eligable-for-exclusion operators +// according to `rng`, selecting the operator according to `index`, and then +// filtering that operator triplet out of `groupMembers`. +// +// In the case that `index` is larger than the number of eligible operator +// triplets, it skips shuffling and returns the number of eligible operators +// triplets, which is useful for logging errors. +func excludeOperatorTriplets( + rng *rand.Rand, + groupMembers []chain.Address, + index int, + operatorToSeatCount map[chain.Address]uint, + operators []chain.Address, + retryParticipantsCount int, +) ([]chain.Address, int, bool) { + tripletIndexes := make([][3]int, 0, len(operators)*len(operators)*len(operators)) + for i := 0; i < len(operators)-2; i++ { + for j := i + 1; j < len(operators)-1; j++ { + for k := j + 1; k < len(operators); k++ { + leftOperator := operators[i] + middleOperator := operators[j] + rightOperator := operators[k] + + // Only include the operators triples that have few enough seats such + // that if they were excluded we still have at least + // `retryParticipantsCount` seats. + count := len(groupMembers) - + int(operatorToSeatCount[leftOperator]) - + int(operatorToSeatCount[middleOperator]) - + int(operatorToSeatCount[rightOperator]) + if count >= int(retryParticipantsCount) { + tripletIndexes = append(tripletIndexes, [3]int{i, j, k}) + } + } + } + } + if index < len(tripletIndexes) { + rng.Shuffle(len(tripletIndexes), func(i, j int) { + tripletIndexes[i], tripletIndexes[j] = tripletIndexes[j], tripletIndexes[i] + }) + triplet := tripletIndexes[index] + leftOperator := operators[triplet[0]] + middleOperator := operators[triplet[1]] + rightOperator := operators[triplet[2]] + usedOperators := make([]chain.Address, 0, len(groupMembers)) + for _, operator := range groupMembers { + if operator != leftOperator && operator != middleOperator && operator != rightOperator { + usedOperators = append(usedOperators, operator) + } + } + return usedOperators, 0, true + } else { + return nil, len(tripletIndexes), false + } +} diff --git a/pkg/frost/retry/retry_test.go b/pkg/frost/retry/retry_test.go new file mode 100644 index 0000000000..5e0a16dbcd --- /dev/null +++ b/pkg/frost/retry/retry_test.go @@ -0,0 +1,290 @@ +package retry + +import ( + "fmt" + "math/rand" + "reflect" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" +) + +type groupMemberRandomizer func( + []chain.Address, + int64, + uint, + uint, +) ([]chain.Address, error) + +func TestEvaluateRetryParticipantsForSigning_100DifferentOperators(t *testing.T) { + groupMembers := make([]chain.Address, 100) + for i := 0; i < 100; i++ { + groupMembers[i] = chain.Address(fmt.Sprintf("Operator-%d", i)) + } + assertInvariants(t, EvaluateRetryParticipantsForSigning, groupMembers, int64(123), 0, 51) +} + +func TestEvaluateRetryParticipantsForSigning_FewOperators(t *testing.T) { + groupMembers := make([]chain.Address, 100) + for i := 0; i < 100; i++ { + groupMembers[i] = chain.Address(fmt.Sprintf("Operator-%d", i%3)) + } + assertInvariants(t, EvaluateRetryParticipantsForSigning, groupMembers, int64(456), 0, 51) +} + +func TestEvaluateRetryParticipantsForSigning_NotEnoughOperators(t *testing.T) { + groupMembers := make([]chain.Address, 50) + for i := 0; i < 50; i++ { + groupMembers[i] = chain.Address(fmt.Sprintf("Operator-%d", i)) + } + _, err := EvaluateRetryParticipantsForSigning(groupMembers, int64(123), 0, 51) + expectation := "asked for too many seats" + if err == nil { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%v]", + fmt.Sprintf("%s...", expectation), + nil, + ) + } + if !strings.HasPrefix(err.Error(), expectation) { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%s]", + fmt.Sprintf("%s...", expectation), + err.Error(), + ) + } +} + +func TestEvaluateRetryParticipantsForKeyGeneration_100DifferentOperators(t *testing.T) { + groupMembers := make([]chain.Address, 100) + for i := 0; i < 100; i++ { + groupMembers[i] = chain.Address(fmt.Sprintf("Operator-%d", i)) + } + assertInvariants(t, EvaluateRetryParticipantsForKeyGeneration, groupMembers, int64(123), 0, 90) +} + +func TestEvaluateRetryParticipantsForKeyGeneration_FewOperators(t *testing.T) { + groupMembers := make([]chain.Address, 100) + for i := 0; i < 100; i++ { + groupMembers[i] = chain.Address(fmt.Sprintf("Operator-%d", i%20)) + } + // There are 20 unique operators, and any 3 of them can be excluded while + // still being above the lower bound of 80 since each operator controls 5 + // seats. Thus, there are 20 single exclusions, 20 choose 2 = 190 pairs, and + // 20 choose 3 = 1140 triplets for a total of 20 + 190 + 1140 = 1350 total + // exclusions. + + // Single exclusion + assertInvariants(t, EvaluateRetryParticipantsForKeyGeneration, groupMembers, int64(456), 15, 80) + + // Pair Exclusion + assertInvariants(t, EvaluateRetryParticipantsForKeyGeneration, groupMembers, int64(456), 170, 80) + + // Triplet Exclusion + assertInvariants(t, EvaluateRetryParticipantsForKeyGeneration, groupMembers, int64(456), 1000, 80) + + // Too many! + _, err := EvaluateRetryParticipantsForKeyGeneration(groupMembers, int64(456), 1350, 80) + expectation := "the retry count [1350] was too large to handle; tried every single, pair, and triplet, but still needed [0] more retries" + if err.Error() != expectation { + t.Errorf( + "unexpected error\nexpected: [%s]\nactual: [%s]", + expectation, + err.Error(), + ) + } +} + +func TestEvaluateRetryParticipantsForKeyGeneration_NotEnoughOperators(t *testing.T) { + groupMembers := make([]chain.Address, 50) + for i := 0; i < 50; i++ { + groupMembers[i] = chain.Address(fmt.Sprintf("Operator-%d", i)) + } + _, err := EvaluateRetryParticipantsForKeyGeneration(groupMembers, int64(123), 0, 90) + expectation := "asked for too many seats" + if err == nil { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%v]", + fmt.Sprintf("%s...", expectation), + nil, + ) + } + if !strings.HasPrefix(err.Error(), expectation) { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%s]", + fmt.Sprintf("%s...", expectation), + err.Error(), + ) + } +} + +func TestExcludeOperatorTriplets_UsesThirdOperatorSeatCount(t *testing.T) { + groupMembers := []chain.Address{ + "A", "A", "A", + "B", + "C", "C", "C", + } + + operatorToSeatCount := calculateSeatCount(groupMembers) + operators := []chain.Address{"A", "B", "C"} + + // #nosec G404 (insecure random number source (rand)) + // Deterministic RNG is sufficient for deterministic unit tests. + rng := rand.New(rand.NewSource(1)) + + usedOperators, skippedTries, ok := excludeOperatorTriplets( + rng, + groupMembers, + 0, + operatorToSeatCount, + operators, + 2, + ) + + if ok { + t.Fatalf( + "expected no eligible triplet exclusions, got operators: [%v]", + usedOperators, + ) + } + + if skippedTries != 0 { + t.Fatalf( + "expected zero skipped tries when no triplet is eligible, got: [%d]", + skippedTries, + ) + } +} + +func isSubset( + t *testing.T, + groupMemberRandomizer groupMemberRandomizer, + groupMembers []chain.Address, + seed int64, + retryCount uint, + retryParticipantsCount uint, +) { + subset, err := groupMemberRandomizer(groupMembers, seed, retryCount, retryParticipantsCount) + if err != nil { + t.Fatalf("unexpected error: [%s]", err) + } + memberMap := make(map[chain.Address]struct{}) + for _, operator := range groupMembers { + memberMap[operator] = struct{}{} + } + for _, operator := range subset { + if _, ok := memberMap[operator]; !ok { + t.Errorf("Subset member [%s] is not in the operator group.", operator) + } + } +} + +func isStable( + t *testing.T, + groupMemberRandomizer groupMemberRandomizer, + groupMembers []chain.Address, + seed int64, + retryCount uint, + retryParticipantsCount uint, +) { + subset, err := groupMemberRandomizer(groupMembers, seed, retryCount, retryParticipantsCount) + if err != nil { + t.Fatalf("unexpected error: [%s]", err) + } + for i := 0; i < 30; i++ { + newSubset, err := groupMemberRandomizer(groupMembers, seed, retryCount, retryParticipantsCount) + if err != nil { + t.Fatalf("unexpected error: [%s]", err) + } + if ok := reflect.DeepEqual(subset, newSubset); !ok { + t.Errorf( + "The subsets changed\nexpected: [%v]\nactual: [%v]", + subset, + newSubset, + ) + } + } +} + +func isLargeEnough( + t *testing.T, + groupMemberRandomizer groupMemberRandomizer, + groupMembers []chain.Address, + seed int64, + retryCount uint, + retryParticipantsCount uint, +) { + subset, err := groupMemberRandomizer(groupMembers, seed, retryCount, retryParticipantsCount) + if err != nil { + t.Fatalf("unexpected error: [%s]", err) + } + if len(subset) < int(retryParticipantsCount) { + t.Errorf( + "Subset isn't large enough\nexpected: [%d+]\nactual: [%d]", + retryParticipantsCount, + len(subset), + ) + } +} + +// They don't all have to be different, but they shouldn't all be the same! +func affectedBySeed( + t *testing.T, + groupMemberRandomizer groupMemberRandomizer, + groupMembers []chain.Address, + originalSeed int64, + retryCount uint, + retryParticipantsCount uint, +) { + allTheSame := true + subset, err := groupMemberRandomizer(groupMembers, originalSeed, retryCount, retryParticipantsCount) + if err != nil { + t.Fatalf("unexpected error: [%s]", err) + } + for seed := int64(0); seed < 30 && allTheSame; seed++ { + newSubset, _ := groupMemberRandomizer(groupMembers, seed, retryCount, retryParticipantsCount) + allTheSame = allTheSame && reflect.DeepEqual(subset, newSubset) + } + if allTheSame { + t.Error("The seed did not affect the subset generation. All subsets were the same.") + } +} + +// They don't all have to be different, but they shouldn't all be the same! +func affectedByRetryCount( + t *testing.T, + groupMemberRandomizer groupMemberRandomizer, + groupMembers []chain.Address, + seed int64, + originalRetryCount uint, + retryParticipantsCount uint, +) { + allTheSame := true + subset, err := groupMemberRandomizer(groupMembers, seed, originalRetryCount, retryParticipantsCount) + if err != nil { + t.Fatalf("unexpected error: [%s]", err) + } + for retryCount := uint(1); retryCount < 30 && allTheSame; retryCount++ { + newSubset, _ := groupMemberRandomizer(groupMembers, seed, retryCount, retryParticipantsCount) + allTheSame = allTheSame && reflect.DeepEqual(subset, newSubset) + } + if allTheSame { + t.Error("The seed did not affect the subset generation. All subsets were the same.") + } +} + +func assertInvariants( + t *testing.T, + groupMemberRandomizer groupMemberRandomizer, + groupMembers []chain.Address, + seed int64, + retryCount uint, + retryParticipantsCount uint, +) { + isSubset(t, groupMemberRandomizer, groupMembers, seed, retryCount, retryParticipantsCount) + isStable(t, groupMemberRandomizer, groupMembers, seed, retryCount, retryParticipantsCount) + isLargeEnough(t, groupMemberRandomizer, groupMembers, seed, retryCount, retryParticipantsCount) + affectedBySeed(t, groupMemberRandomizer, groupMembers, seed, retryCount, retryParticipantsCount) + affectedByRetryCount(t, groupMemberRandomizer, groupMembers, seed, retryCount, retryParticipantsCount) +} diff --git a/pkg/frost/roast/attempt/attempt_context.go b/pkg/frost/roast/attempt/attempt_context.go new file mode 100644 index 0000000000..772ecdaa02 --- /dev/null +++ b/pkg/frost/roast/attempt/attempt_context.go @@ -0,0 +1,299 @@ +// Package attempt implements the AttemptContext type that binds every +// signing-protocol message to a deterministic, group-agreed context. +// +// This package is the Phase 1 deliverable from RFC-21 (ROAST Coordinator, +// Retry, and Transition Evidence). It introduces only the type, its +// deterministic seed derivation, and the canonical hash used to bind +// protocol messages to an attempt. No protocol behaviour changes in this +// phase; consumers are wired in later phases behind build tags. +package attempt + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// MessageDigestLength is the canonical byte length of a signing-message +// digest carried in AttemptContext. The protocol always uses SHA-256 +// digests of the BIP-340 tag-bound payload, so 32 bytes is correct for +// every signing flow this package is concerned with. +const MessageDigestLength = 32 + +// AttemptSeedLength is the canonical byte length of the per-attempt +// participant-shuffle seed. The seed is derived, never chosen -- +// see DeriveAttemptSeed. +const AttemptSeedLength = 32 + +// AttemptContext binds an in-flight ROAST signing attempt to a +// deterministic context. Every honest signer must construct the same +// AttemptContext for a given (session, key group, message, attempt +// number) and must reject any protocol message whose AttemptContextHash +// does not match the locally-computed context. +// +// AttemptContext fields are public so test fixtures can construct +// contexts directly, but production callers should use NewAttemptContext +// which validates inputs and derives the seed. +type AttemptContext struct { + // SessionID identifies the signing session at the keep-core layer. + // It is opaque to the ROAST coordinator; the coordinator only + // requires it to be stable across the session's attempts. + SessionID string + // KeyGroupID identifies the FROST key group whose threshold share + // will sign. It is opaque to the coordinator; equality across honest + // signers is required. + KeyGroupID string + // MessageDigest is the 32-byte SHA-256 digest of the BIP-340 + // tag-bound signing message. + MessageDigest [MessageDigestLength]byte + // AttemptNumber is the zero-based ordinal of this attempt within + // the session. Attempt 0 is the first attempt; later attempts are + // driven by NextAttempt in the coordinator state machine + // (introduced in later RFC-21 phases). + AttemptNumber uint32 + // IncludedSet is the set of member indices that are eligible to + // participate in this attempt. Must be sorted ascending. Must not + // be empty. + IncludedSet []group.MemberIndex + // ExcludedSet is the set of member indices permanently excluded + // from this attempt by the coordinator's transition-evidence + // policy. Must be sorted ascending. May be empty. Permanent + // exclusion follows from transport-blamable (overflow) or + // validation-blamable (non-transport reject) evidence, never + // from silence alone. + ExcludedSet []group.MemberIndex + // TransientlyParked is the set of member indices skipped from + // THIS attempt only because they were silent (deadline expiry) + // at the previous attempt. Parking is strictly transient: a + // peer is unparked at the attempt after the one that skipped + // them, so a falsely-silenced honest peer (network blip, + // coordinator censorship caught at VerifyBundle) is reinstated + // without intervention. Must be sorted ascending. May be empty. + TransientlyParked []group.MemberIndex + // AttemptSeed is derived from group-agreed inputs and binds the + // attempt to inputs that no coordinator can manipulate. See + // DeriveAttemptSeed. + AttemptSeed [AttemptSeedLength]byte +} + +// DeriveAttemptSeed computes the per-attempt seed from inputs the group +// already agrees on. The seed binds the attempt's participant selection +// to fixed session inputs so a coordinator cannot shape the shuffle by +// picking a favourable seed. +// +// The derivation is: +// +// AttemptSeed = SHA256( +// DkgGroupPublicKey || SessionID || MessageDigest, +// ) +// +// Where SessionID is encoded as the raw UTF-8 bytes (the canonical +// representation used elsewhere in keep-core) and the other inputs are +// raw bytes. +func DeriveAttemptSeed( + dkgGroupPublicKey []byte, + sessionID string, + messageDigest [MessageDigestLength]byte, +) [AttemptSeedLength]byte { + h := sha256.New() + h.Write(dkgGroupPublicKey) + h.Write([]byte(sessionID)) + h.Write(messageDigest[:]) + var out [AttemptSeedLength]byte + copy(out[:], h.Sum(nil)) + return out +} + +// NewAttemptContext constructs an AttemptContext with the seed derived +// from group-agreed inputs. The IncludedSet and ExcludedSet are sorted +// ascending in the returned context regardless of input order; honest +// signers therefore produce identical contexts from identical input +// values. +// +// Returns an error if the included set is empty, if any member appears +// in both sets, or if either set contains duplicates. +// +// This is the seven-argument convenience that initialises an attempt +// with no TransientlyParked entries (the attempt-zero shape). For +// later attempts produced by the coordinator's NextAttempt policy, +// use NewAttemptContextWithParking. +func NewAttemptContext( + sessionID string, + keyGroupID string, + dkgGroupPublicKey []byte, + messageDigest [MessageDigestLength]byte, + attemptNumber uint32, + includedSet []group.MemberIndex, + excludedSet []group.MemberIndex, +) (AttemptContext, error) { + return NewAttemptContextWithParking( + sessionID, + keyGroupID, + dkgGroupPublicKey, + messageDigest, + attemptNumber, + includedSet, + excludedSet, + nil, + ) +} + +// NewAttemptContextWithParking is the full constructor used by the +// coordinator's NextAttempt policy. It accepts a transientlyParked +// set in addition to the inputs of NewAttemptContext. +// +// Validation: included set non-empty; no duplicates in any set; +// included/excluded sets disjoint; included/parked sets disjoint; +// excluded/parked sets disjoint. +func NewAttemptContextWithParking( + sessionID string, + keyGroupID string, + dkgGroupPublicKey []byte, + messageDigest [MessageDigestLength]byte, + attemptNumber uint32, + includedSet []group.MemberIndex, + excludedSet []group.MemberIndex, + transientlyParked []group.MemberIndex, +) (AttemptContext, error) { + if len(includedSet) == 0 { + return AttemptContext{}, errors.New( + "attempt context: included set must not be empty", + ) + } + included, err := canonicalMemberSet(includedSet, "included") + if err != nil { + return AttemptContext{}, err + } + excluded, err := canonicalMemberSet(excludedSet, "excluded") + if err != nil { + return AttemptContext{}, err + } + parked, err := canonicalMemberSet(transientlyParked, "transiently parked") + if err != nil { + return AttemptContext{}, err + } + if hasOverlap(included, excluded) { + return AttemptContext{}, errors.New( + "attempt context: included and excluded sets overlap", + ) + } + if hasOverlap(included, parked) { + return AttemptContext{}, errors.New( + "attempt context: included and transiently-parked sets overlap", + ) + } + if hasOverlap(excluded, parked) { + return AttemptContext{}, errors.New( + "attempt context: excluded and transiently-parked sets overlap", + ) + } + return AttemptContext{ + SessionID: sessionID, + KeyGroupID: keyGroupID, + MessageDigest: messageDigest, + AttemptNumber: attemptNumber, + IncludedSet: included, + ExcludedSet: excluded, + TransientlyParked: parked, + AttemptSeed: DeriveAttemptSeed( + dkgGroupPublicKey, + sessionID, + messageDigest, + ), + }, nil +} + +// Hash returns the canonical 32-byte hash of the attempt context. The +// hash is the SHA-256 of a length-prefixed, sorted-set canonical +// encoding so any two honest signers that construct semantically equal +// AttemptContexts produce byte-identical hashes regardless of input +// ordering. +// +// The hash is the value carried in protocol messages as +// AttemptContextHash. A receiver that computes a different hash than +// the one carried by an inbound message must reject the message: it +// belongs to a different attempt. +func (c AttemptContext) Hash() [MessageDigestLength]byte { + h := sha256.New() + writeLenPrefixed(h, []byte(c.SessionID)) + writeLenPrefixed(h, []byte(c.KeyGroupID)) + h.Write(c.MessageDigest[:]) + var attemptNumberBuf [4]byte + binary.BigEndian.PutUint32(attemptNumberBuf[:], c.AttemptNumber) + h.Write(attemptNumberBuf[:]) + writeMemberSet(h, c.IncludedSet) + writeMemberSet(h, c.ExcludedSet) + writeMemberSet(h, c.TransientlyParked) + h.Write(c.AttemptSeed[:]) + var out [MessageDigestLength]byte + copy(out[:], h.Sum(nil)) + return out +} + +func canonicalMemberSet( + members []group.MemberIndex, + label string, +) ([]group.MemberIndex, error) { + if len(members) == 0 { + return []group.MemberIndex{}, nil + } + out := make([]group.MemberIndex, len(members)) + copy(out, members) + sort.Slice(out, func(i, j int) bool { + return out[i] < out[j] + }) + for i := 1; i < len(out); i++ { + if out[i] == out[i-1] { + return nil, fmt.Errorf( + "attempt context: %s set contains duplicate member [%d]", + label, + out[i], + ) + } + } + return out, nil +} + +func hasOverlap(a, b []group.MemberIndex) bool { + i, j := 0, 0 + for i < len(a) && j < len(b) { + switch { + case a[i] < b[j]: + i++ + case a[i] > b[j]: + j++ + default: + return true + } + } + return false +} + +// byteWriter is the subset of io.Writer the canonical-encoding helpers +// need. Hash.Write (the only production implementation) is documented to +// never return an error, so the helpers discard the (int, error) result +// explicitly to make that contract reader-visible (and to satisfy gosec +// G104). +type byteWriter interface { + Write(p []byte) (n int, err error) +} + +func writeLenPrefixed(w byteWriter, data []byte) { + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(data))) + _, _ = w.Write(lenBuf[:]) + _, _ = w.Write(data) +} + +func writeMemberSet(w byteWriter, members []group.MemberIndex) { + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(members))) + _, _ = w.Write(lenBuf[:]) + for _, m := range members { + _, _ = w.Write([]byte{byte(m)}) + } +} diff --git a/pkg/frost/roast/attempt/attempt_context_test.go b/pkg/frost/roast/attempt/attempt_context_test.go new file mode 100644 index 0000000000..13c5408a8c --- /dev/null +++ b/pkg/frost/roast/attempt/attempt_context_test.go @@ -0,0 +1,435 @@ +package attempt + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestDeriveAttemptSeed_IsPureFunctionOfInputs(t *testing.T) { + dkgPub := []byte{0x02, 0x01, 0x02, 0x03, 0x04} + sessionID := "session-a" + var digest [MessageDigestLength]byte + copy(digest[:], bytes.Repeat([]byte{0x42}, MessageDigestLength)) + + a := DeriveAttemptSeed(dkgPub, sessionID, digest) + b := DeriveAttemptSeed(dkgPub, sessionID, digest) + if a != b { + t.Fatalf("derivation not deterministic: %x != %x", a, b) + } + + expected := sha256.Sum256( + append(append(append([]byte{}, dkgPub...), []byte(sessionID)...), digest[:]...), + ) + if a != expected { + t.Fatalf( + "derivation does not match SHA256(dkgPub || sessionID || messageDigest): got %x want %x", + a, expected, + ) + } +} + +func TestDeriveAttemptSeed_SensitiveToEachInput(t *testing.T) { + base := DeriveAttemptSeed( + []byte{0x01, 0x02}, + "session-a", + [MessageDigestLength]byte{0x01}, + ) + + tests := []struct { + name string + dkgPub []byte + sessionID string + digest [MessageDigestLength]byte + }{ + { + name: "different DKG public key", + dkgPub: []byte{0x01, 0x03}, + sessionID: "session-a", + digest: [MessageDigestLength]byte{0x01}, + }, + { + name: "different session ID", + dkgPub: []byte{0x01, 0x02}, + sessionID: "session-b", + digest: [MessageDigestLength]byte{0x01}, + }, + { + name: "different message digest", + dkgPub: []byte{0x01, 0x02}, + sessionID: "session-a", + digest: [MessageDigestLength]byte{0x02}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DeriveAttemptSeed(tt.dkgPub, tt.sessionID, tt.digest) + if got == base { + t.Fatalf("seed collided with base for %s", tt.name) + } + }) + } +} + +func TestNewAttemptContext_SortsAndDeduplicates(t *testing.T) { + dkgPub := []byte{0x01} + digest := [MessageDigestLength]byte{0xaa} + + included := []group.MemberIndex{5, 3, 4, 1, 2} + excluded := []group.MemberIndex{7, 6} + + ctx, err := NewAttemptContext( + "session", "key-group", dkgPub, digest, 0, included, excluded, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []group.MemberIndex{1, 2, 3, 4, 5} + if !memberSlicesEqual(ctx.IncludedSet, want) { + t.Fatalf( + "included set not sorted: got %v want %v", + ctx.IncludedSet, want, + ) + } + wantExcluded := []group.MemberIndex{6, 7} + if !memberSlicesEqual(ctx.ExcludedSet, wantExcluded) { + t.Fatalf( + "excluded set not sorted: got %v want %v", + ctx.ExcludedSet, wantExcluded, + ) + } + + if !bytes.Equal(included, []group.MemberIndex{5, 3, 4, 1, 2}) { + t.Fatalf( + "caller's included slice was mutated: %v", + included, + ) + } +} + +func TestNewAttemptContext_RejectsEmptyIncludedSet(t *testing.T) { + _, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{}, 0, + nil, nil, + ) + if err == nil { + t.Fatal("expected error for empty included set") + } + if !strings.Contains(err.Error(), "included set must not be empty") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestNewAttemptContext_RejectsDuplicates(t *testing.T) { + tests := []struct { + name string + included []group.MemberIndex + excluded []group.MemberIndex + want string + }{ + { + name: "duplicate in included set", + included: []group.MemberIndex{1, 2, 2, 3}, + excluded: nil, + want: "included set contains duplicate", + }, + { + name: "duplicate in excluded set", + included: []group.MemberIndex{1, 2}, + excluded: []group.MemberIndex{4, 4}, + want: "excluded set contains duplicate", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{}, 0, + tt.included, tt.excluded, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf( + "unexpected error message: got %q want substring %q", + err.Error(), tt.want, + ) + } + }) + } +} + +func TestNewAttemptContext_RejectsOverlap(t *testing.T) { + _, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{}, 0, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{3, 4}, + ) + if err == nil { + t.Fatal("expected overlap error") + } + if !strings.Contains(err.Error(), "overlap") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAttemptContextHash_IsDeterministicAcrossInputOrdering(t *testing.T) { + dkgPub := []byte{0xab, 0xcd} + digest := [MessageDigestLength]byte{0x77} + + ctxA, err := NewAttemptContext( + "session", "kg", dkgPub, digest, 7, + []group.MemberIndex{5, 3, 4, 1, 2}, + []group.MemberIndex{7, 6}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctxB, err := NewAttemptContext( + "session", "kg", dkgPub, digest, 7, + []group.MemberIndex{1, 2, 3, 4, 5}, + []group.MemberIndex{6, 7}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ctxA.Hash() != ctxB.Hash() { + t.Fatalf( + "semantically equal contexts produced different hashes: %x vs %x", + ctxA.Hash(), ctxB.Hash(), + ) + } +} + +func TestAttemptContextHash_SensitiveToEachField(t *testing.T) { + base, err := NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + baseHash := base.Hash() + + type mutator struct { + name string + fn func() (AttemptContext, error) + } + mutators := []mutator{ + { + name: "different session ID", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session-2", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different key group ID", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg-2", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different message digest", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x06}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different attempt number", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 4, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different included set", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3, 5}, + []group.MemberIndex{4}, + ) + }, + }, + { + name: "different excluded set", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x01}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + nil, + ) + }, + }, + { + name: "different DKG public key", + fn: func() (AttemptContext, error) { + return NewAttemptContext( + "session", "kg", []byte{0x02}, + [MessageDigestLength]byte{0x05}, 3, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4}, + ) + }, + }, + } + + for _, m := range mutators { + t.Run(m.name, func(t *testing.T) { + ctx, err := m.fn() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctx.Hash() == baseHash { + t.Fatalf( + "%s did not change hash; base=%x mutated=%x", + m.name, baseHash, ctx.Hash(), + ) + } + }) + } +} + +func TestAttemptContextHash_PrefixesAvoidStringConcatCollision(t *testing.T) { + // Without length-prefixed encoding, ("ab", "cd") and ("a", "bcd") would + // produce identical hashes. Verify they do not. + dkgPub := []byte{0x01} + digest := [MessageDigestLength]byte{} + + ctxA, err := NewAttemptContext( + "ab", "cd", dkgPub, digest, 0, + []group.MemberIndex{1}, nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ctxB, err := NewAttemptContext( + "a", "bcd", dkgPub, digest, 0, + []group.MemberIndex{1}, nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctxA.Hash() == ctxB.Hash() { + t.Fatalf( + "concatenated session+keyGroup collide: hash=%x", + ctxA.Hash(), + ) + } +} + +func TestAttemptContextHash_IsStableAcrossSafeFieldExtensions(t *testing.T) { + // Lock the wire encoding by asserting a specific hash output for a + // pinned fixture. If a future change to the canonical encoding + // changes this hash, that change is a wire-format break and must be + // caught at code review. + ctx, err := NewAttemptContext( + "session-pinned", + "key-group-pinned", + []byte{0xAA, 0xBB, 0xCC, 0xDD}, + [MessageDigestLength]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + }, + 42, + []group.MemberIndex{1, 2, 3}, + []group.MemberIndex{4, 5}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Recompute the expected hash by independently re-implementing the + // canonical encoding here so the test catches accidental drift in + // either the production encoder or the expected hash literal. + want := referenceHashForFixture(ctx) + got := ctx.Hash() + if got != want { + t.Fatalf( + "pinned fixture hash drifted: got %x want %x", + got, want, + ) + } +} + +func memberSlicesEqual(a, b []group.MemberIndex) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// referenceHashForFixture implements the canonical encoding inline so +// the pinned-fixture test catches drift in either the production +// implementation or the test literal. +func referenceHashForFixture(ctx AttemptContext) [MessageDigestLength]byte { + h := sha256.New() + writeLP := func(b []byte) { + var l [4]byte + binary.BigEndian.PutUint32(l[:], uint32(len(b))) + h.Write(l[:]) + h.Write(b) + } + writeMS := func(ms []group.MemberIndex) { + var l [4]byte + binary.BigEndian.PutUint32(l[:], uint32(len(ms))) + h.Write(l[:]) + for _, m := range ms { + h.Write([]byte{byte(m)}) + } + } + + writeLP([]byte(ctx.SessionID)) + writeLP([]byte(ctx.KeyGroupID)) + h.Write(ctx.MessageDigest[:]) + var a [4]byte + binary.BigEndian.PutUint32(a[:], ctx.AttemptNumber) + h.Write(a[:]) + writeMS(ctx.IncludedSet) + writeMS(ctx.ExcludedSet) + writeMS(ctx.TransientlyParked) + h.Write(ctx.AttemptSeed[:]) + var out [MessageDigestLength]byte + copy(out[:], h.Sum(nil)) + return out +} diff --git a/pkg/frost/roast/attempt/evidence_recorder.go b/pkg/frost/roast/attempt/evidence_recorder.go new file mode 100644 index 0000000000..b67d23513c --- /dev/null +++ b/pkg/frost/roast/attempt/evidence_recorder.go @@ -0,0 +1,253 @@ +package attempt + +import ( + "sync" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// OverflowQuotaDefault is the default per-sender overflow event quota +// enforced by NewBoundedRecorder. It matches the categoryQuota.Overflow +// value documented in RFC-21 Layer A. +// +// A peer that overflows the inbound message channel more than the +// quota allows in a single attempt is recorded only up to the quota: +// further overflows are silently dropped by the recorder. This bounds +// the per-attempt evidence size to O(|IncludedSet| * quota) regardless +// of how aggressively a peer (or its network link) misbehaves. +const OverflowQuotaDefault uint = 8 + +// RejectQuotaDefault is the default per-sender reject event quota. +// Matches categoryQuota.Reject in RFC-21 Layer A. A reject event is +// recorded each time a peer's payload fails the validation gate +// (shouldAcceptNativeFROSTMessage returning false), regardless of +// the specific reason. +const RejectQuotaDefault uint = 8 + +// ConflictQuotaDefault is the default per-sender conflict event +// quota. Matches categoryQuota.Conflict in RFC-21 Layer A. A +// conflict event is recorded when a peer retransmits a message for +// a sender slot that already holds a byte-different contribution +// (first-write-wins reject). +const ConflictQuotaDefault uint = 4 + +// EvidenceRecorder collects bounded, per-attempt evidence of receive- +// path anomalies that the ROAST coordinator's exclusion policy may +// later consume. +// +// The interface tracks three categories of evidence: +// - Overflow: payload arrived but the inbound channel was full. +// - Reject: payload arrived but failed validation +// (shouldAcceptNativeFROSTMessage returning false). +// - Conflict: a peer's later retransmission disagreed with its +// earlier contribution for the same slot (equivocation +// signal). +// +// Silence -- peers in the IncludedSet that produced no snapshot at +// all -- is derived implicitly by the NextAttempt policy from +// (ctx.IncludedSet - bundleSenders) and does not need a recorder +// method. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines, since the receive-callback closure in pkg/frost/signing +// is driven by network goroutines. +type EvidenceRecorder interface { + // RecordOverflow notes that the inbound message channel was full + // when a payload from the named sender arrived, causing the + // payload to be dropped at the receive callback. The recorder + // applies its own quota; callers do not need to suppress at the + // call site. + RecordOverflow(sender group.MemberIndex) + // RecordReject notes that a payload from the named sender failed + // the validation gate (typically shouldAcceptNativeFROSTMessage + // returning false). The reason string is preserved verbatim in + // the snapshot so the coordinator's exclusion policy can later + // route by reason if needed; the recorder applies its own + // per-sender quota regardless of reason. + RecordReject(sender group.MemberIndex, reason string) + // RecordConflict notes that a peer retransmitted a message for + // a sender slot that already holds a byte-different contribution + // (equivocation signal under the first-write-wins assembly + // policy). + RecordConflict(sender group.MemberIndex) + // Snapshot returns a copy of the recorded evidence so far. The + // returned value does not alias internal state; the recorder may + // continue receiving events after Snapshot is called. + Snapshot() Evidence +} + +// RejectEntry describes a single per-sender reject event recorded +// during an attempt. The reason captures *why* the validation gate +// rejected the payload; the coordinator's exclusion policy treats +// every distinct reason as equally blamable today, but the field +// is kept structured so future policy refinements can differentiate. +type RejectEntry struct { + Reason string + Count uint +} + +// Evidence is the per-attempt snapshot of receive-path anomalies +// captured by an EvidenceRecorder. It is the value the ROAST +// coordinator's NextAttempt policy consumes to derive the next +// attempt's ExcludedSet. +// +// Maps are nil-safe in callers: an absent key means the category +// did not fire for that sender, count zero. +type Evidence struct { + // Overflows maps each sender to the number of overflow events + // observed for that sender during the attempt, saturated at the + // recorder's overflow quota. + Overflows map[group.MemberIndex]uint + // Rejects maps each sender to a per-reason set of reject entries. + // The outer map's key is the sender; the inner slice carries one + // entry per distinct reason, with Count saturated at the + // recorder's reject quota. + Rejects map[group.MemberIndex][]RejectEntry + // Conflicts maps each sender to the number of first-write-wins + // conflict events observed during the attempt, saturated at the + // recorder's conflict quota. + Conflicts map[group.MemberIndex]uint +} + +// NewBoundedRecorder returns an EvidenceRecorder with default +// per-sender quotas across all three categories. The recorder is +// safe for concurrent use. +func NewBoundedRecorder() EvidenceRecorder { + return NewBoundedRecorderWithQuotas( + OverflowQuotaDefault, + RejectQuotaDefault, + ConflictQuotaDefault, + ) +} + +// NewBoundedRecorderWithQuota returns a recorder with a custom +// overflow quota; reject and conflict quotas use their defaults. +// Preserved as the Phase-2 entry point so existing callers do not +// need to update. +func NewBoundedRecorderWithQuota(overflowQuota uint) EvidenceRecorder { + return NewBoundedRecorderWithQuotas( + overflowQuota, + RejectQuotaDefault, + ConflictQuotaDefault, + ) +} + +// NewBoundedRecorderWithQuotas returns a recorder with custom +// per-category quotas. Intended for tests; production callers +// should use NewBoundedRecorder so the per-attempt evidence size +// is uniform across the network. +func NewBoundedRecorderWithQuotas( + overflowQuota, rejectQuota, conflictQuota uint, +) EvidenceRecorder { + return &boundedRecorder{ + overflowQuota: overflowQuota, + rejectQuota: rejectQuota, + conflictQuota: conflictQuota, + overflows: map[group.MemberIndex]uint{}, + rejects: map[group.MemberIndex]map[string]uint{}, + conflicts: map[group.MemberIndex]uint{}, + } +} + +// NoOpRecorder returns a recorder that discards every event and +// reports an empty Evidence on Snapshot. It is the default at +// every receive-loop call site when the ROAST-retry registry is +// not populated, so the receive loops' observable behaviour stays +// identical to pre-Phase-2 until a real recorder is wired. +func NoOpRecorder() EvidenceRecorder { + return noOpRecorder{} +} + +type boundedRecorder struct { + mu sync.Mutex + overflowQuota uint + rejectQuota uint + conflictQuota uint + overflows map[group.MemberIndex]uint + // rejects[sender][reason] = count. The two-level map keeps each + // reason bucket bounded by rejectQuota independently so a peer + // cannot saturate one reason to mask another (RFC-21 Layer A: + // "a peer cannot spam overflow events to drown out reject + // evidence or vice-versa"; the same principle applies within + // reject reasons). + rejects map[group.MemberIndex]map[string]uint + conflicts map[group.MemberIndex]uint +} + +func (r *boundedRecorder) RecordOverflow(sender group.MemberIndex) { + r.mu.Lock() + defer r.mu.Unlock() + if r.overflows[sender] < r.overflowQuota { + r.overflows[sender]++ + } +} + +func (r *boundedRecorder) RecordReject( + sender group.MemberIndex, + reason string, +) { + r.mu.Lock() + defer r.mu.Unlock() + bySender, ok := r.rejects[sender] + if !ok { + bySender = map[string]uint{} + r.rejects[sender] = bySender + } + if bySender[reason] < r.rejectQuota { + bySender[reason]++ + } +} + +func (r *boundedRecorder) RecordConflict(sender group.MemberIndex) { + r.mu.Lock() + defer r.mu.Unlock() + if r.conflicts[sender] < r.conflictQuota { + r.conflicts[sender]++ + } +} + +func (r *boundedRecorder) Snapshot() Evidence { + r.mu.Lock() + defer r.mu.Unlock() + overflows := make(map[group.MemberIndex]uint, len(r.overflows)) + for sender, count := range r.overflows { + overflows[sender] = count + } + rejects := make( + map[group.MemberIndex][]RejectEntry, + len(r.rejects), + ) + for sender, reasons := range r.rejects { + entries := make([]RejectEntry, 0, len(reasons)) + for reason, count := range reasons { + entries = append(entries, RejectEntry{ + Reason: reason, + Count: count, + }) + } + rejects[sender] = entries + } + conflicts := make(map[group.MemberIndex]uint, len(r.conflicts)) + for sender, count := range r.conflicts { + conflicts[sender] = count + } + return Evidence{ + Overflows: overflows, + Rejects: rejects, + Conflicts: conflicts, + } +} + +type noOpRecorder struct{} + +func (noOpRecorder) RecordOverflow(group.MemberIndex) {} +func (noOpRecorder) RecordReject(group.MemberIndex, string) {} +func (noOpRecorder) RecordConflict(group.MemberIndex) {} + +func (noOpRecorder) Snapshot() Evidence { + return Evidence{ + Overflows: map[group.MemberIndex]uint{}, + Rejects: map[group.MemberIndex][]RejectEntry{}, + Conflicts: map[group.MemberIndex]uint{}, + } +} diff --git a/pkg/frost/roast/attempt/evidence_recorder_categories_test.go b/pkg/frost/roast/attempt/evidence_recorder_categories_test.go new file mode 100644 index 0000000000..176d61f152 --- /dev/null +++ b/pkg/frost/roast/attempt/evidence_recorder_categories_test.go @@ -0,0 +1,114 @@ +package attempt + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestBoundedRecorder_RecordReject_AccumulatesByReason(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordReject(1, "validation_gate_rejected") + rec.RecordReject(1, "validation_gate_rejected") + rec.RecordReject(1, "some_other_reason") + + snap := rec.Snapshot() + entries := snap.Rejects[1] + if len(entries) != 2 { + t.Fatalf("expected 2 reject reasons, got %d", len(entries)) + } + counts := map[string]uint{} + for _, e := range entries { + counts[e.Reason] = e.Count + } + if counts["validation_gate_rejected"] != 2 { + t.Fatalf("validation_gate_rejected count: got %d want 2", counts["validation_gate_rejected"]) + } + if counts["some_other_reason"] != 1 { + t.Fatalf("some_other_reason count: got %d want 1", counts["some_other_reason"]) + } +} + +func TestBoundedRecorder_RecordReject_PerReasonQuota(t *testing.T) { + rec := NewBoundedRecorderWithQuotas(8, 3, 4) + for i := 0; i < 10; i++ { + rec.RecordReject(1, "spam") + } + snap := rec.Snapshot() + got := snap.Rejects[1][0].Count + if got != 3 { + t.Fatalf("reject quota not enforced: got %d, want 3", got) + } +} + +func TestBoundedRecorder_RecordReject_PerReasonQuotasIndependent(t *testing.T) { + // A peer cannot saturate one reason to mask another -- each + // reason has its own quota counter. + rec := NewBoundedRecorderWithQuotas(8, 2, 4) + for i := 0; i < 10; i++ { + rec.RecordReject(1, "reason-A") + } + rec.RecordReject(1, "reason-B") + snap := rec.Snapshot() + counts := map[string]uint{} + for _, e := range snap.Rejects[1] { + counts[e.Reason] = e.Count + } + if counts["reason-A"] != 2 { + t.Fatalf("reason-A saturated at: got %d want 2", counts["reason-A"]) + } + if counts["reason-B"] != 1 { + t.Fatalf("reason-B counted independently: got %d want 1", counts["reason-B"]) + } +} + +func TestBoundedRecorder_RecordConflict_AccumulatesAndSaturates(t *testing.T) { + rec := NewBoundedRecorderWithQuotas(8, 8, 2) + rec.RecordConflict(7) + rec.RecordConflict(7) + rec.RecordConflict(7) + rec.RecordConflict(7) + snap := rec.Snapshot() + if got := snap.Conflicts[7]; got != 2 { + t.Fatalf("conflict count saturated at quota; got %d want 2", got) + } +} + +func TestBoundedRecorder_AllCategoriesPresentInSnapshot(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordOverflow(1) + rec.RecordReject(2, "validation_gate_rejected") + rec.RecordConflict(3) + snap := rec.Snapshot() + if snap.Overflows[1] == 0 { + t.Fatal("overflow not recorded") + } + if len(snap.Rejects[2]) == 0 { + t.Fatal("reject not recorded") + } + if snap.Conflicts[3] == 0 { + t.Fatal("conflict not recorded") + } +} + +func TestNoOpRecorder_AllCategoriesInert(t *testing.T) { + rec := NoOpRecorder() + for i := 0; i < 100; i++ { + rec.RecordOverflow(group.MemberIndex(i % 5)) + rec.RecordReject(group.MemberIndex(i%5), "spam") + rec.RecordConflict(group.MemberIndex(i % 5)) + } + snap := rec.Snapshot() + if len(snap.Overflows) != 0 || len(snap.Rejects) != 0 || len(snap.Conflicts) != 0 { + t.Fatalf("NoOp recorder must report empty snapshot; got %+v", snap) + } +} + +func TestRejectAndConflictQuotaConstants_MatchRFC(t *testing.T) { + if RejectQuotaDefault != 8 { + t.Fatalf("RFC-21 specifies reject quota = 8; constant is %d", RejectQuotaDefault) + } + if ConflictQuotaDefault != 4 { + t.Fatalf("RFC-21 specifies conflict quota = 4; constant is %d", ConflictQuotaDefault) + } +} diff --git a/pkg/frost/roast/attempt/evidence_recorder_test.go b/pkg/frost/roast/attempt/evidence_recorder_test.go new file mode 100644 index 0000000000..c36ba6abc3 --- /dev/null +++ b/pkg/frost/roast/attempt/evidence_recorder_test.go @@ -0,0 +1,141 @@ +package attempt + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestNoOpRecorder_IsObservablyInert(t *testing.T) { + rec := NoOpRecorder() + for i := 0; i < 1000; i++ { + rec.RecordOverflow(group.MemberIndex(i%5 + 1)) + } + snap := rec.Snapshot() + if len(snap.Overflows) != 0 { + t.Fatalf( + "NoOp recorder must report zero overflows; got %d entries", + len(snap.Overflows), + ) + } +} + +func TestBoundedRecorder_CountsOverflowsBySender(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordOverflow(1) + rec.RecordOverflow(2) + rec.RecordOverflow(1) + + snap := rec.Snapshot() + if got := snap.Overflows[1]; got != 2 { + t.Fatalf("sender 1 overflow count: got %d want 2", got) + } + if got := snap.Overflows[2]; got != 1 { + t.Fatalf("sender 2 overflow count: got %d want 1", got) + } + if _, ok := snap.Overflows[3]; ok { + t.Fatal("sender 3 should have no entry") + } +} + +func TestBoundedRecorder_SaturatesAtQuota(t *testing.T) { + const quota uint = 4 + rec := NewBoundedRecorderWithQuota(quota) + + for i := uint(0); i < quota+10; i++ { + rec.RecordOverflow(1) + } + snap := rec.Snapshot() + if got := snap.Overflows[1]; got != quota { + t.Fatalf( + "overflow count must saturate at quota %d; got %d", + quota, got, + ) + } +} + +func TestBoundedRecorder_DefaultQuotaIs8(t *testing.T) { + rec := NewBoundedRecorder() + for i := 0; i < 100; i++ { + rec.RecordOverflow(1) + } + if got := rec.Snapshot().Overflows[1]; got != OverflowQuotaDefault { + t.Fatalf( + "default quota mismatch; got %d want %d", + got, OverflowQuotaDefault, + ) + } + if OverflowQuotaDefault != 8 { + t.Fatalf( + "RFC-21 Layer A specifies overflow quota = 8; constant is %d", + OverflowQuotaDefault, + ) + } +} + +func TestBoundedRecorder_SnapshotIsDeepCopy(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordOverflow(1) + rec.RecordOverflow(1) + + snap := rec.Snapshot() + snap.Overflows[1] = 999 + snap.Overflows[42] = 7 + + freshSnap := rec.Snapshot() + if got := freshSnap.Overflows[1]; got != 2 { + t.Fatalf( + "snapshot mutation leaked into recorder state: got %d want 2", + got, + ) + } + if _, ok := freshSnap.Overflows[42]; ok { + t.Fatal("snapshot mutation leaked a new key into recorder state") + } +} + +func TestBoundedRecorder_ConcurrentRecordersAreRaceSafe(t *testing.T) { + const ( + recordersPerSender = 8 + sendersN = 16 + recordsPerRecorder = 200 + ) + rec := NewBoundedRecorderWithQuota(uint(recordersPerSender * recordsPerRecorder * 10)) + + var wg sync.WaitGroup + for senderIdx := 1; senderIdx <= sendersN; senderIdx++ { + sender := group.MemberIndex(senderIdx) + for w := 0; w < recordersPerSender; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for n := 0; n < recordsPerRecorder; n++ { + rec.RecordOverflow(sender) + } + }() + } + } + wg.Wait() + + snap := rec.Snapshot() + for senderIdx := 1; senderIdx <= sendersN; senderIdx++ { + want := uint(recordersPerSender * recordsPerRecorder) + if got := snap.Overflows[group.MemberIndex(senderIdx)]; got != want { + t.Fatalf( + "sender %d concurrent count: got %d want %d", + senderIdx, got, want, + ) + } + } +} + +func TestNoOpRecorder_DistinctInstancesShareSemantics(t *testing.T) { + a := NoOpRecorder() + b := NoOpRecorder() + a.RecordOverflow(1) + b.RecordOverflow(2) + if len(a.Snapshot().Overflows) != 0 || len(b.Snapshot().Overflows) != 0 { + t.Fatal("NoOp instances must not retain state") + } +} diff --git a/pkg/frost/roast/bundle_aggregation_test.go b/pkg/frost/roast/bundle_aggregation_test.go new file mode 100644 index 0000000000..412c63db24 --- /dev/null +++ b/pkg/frost/roast/bundle_aggregation_test.go @@ -0,0 +1,564 @@ +package roast + +import ( + "bytes" + "errors" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// pickNonCoordinatorMember returns the first member of `set` that is +// not equal to `elected`. Fatals if no such member exists. Used by +// receiver-side tests that need a member distinct from the +// aggregator. +func pickNonCoordinatorMember( + t *testing.T, + set []group.MemberIndex, + elected group.MemberIndex, +) group.MemberIndex { + t.Helper() + for _, m := range set { + if m != elected { + return m + } + } + t.Fatalf("no non-coordinator member available; set=%v elected=%d", set, elected) + return 0 +} + +// signSnapshotForTest mints a fakeSigner signature on a snapshot and +// stores it on the snapshot's OperatorSignature field. Returns the +// snapshot for chaining. +func signSnapshotForTest( + t *testing.T, + snap *LocalEvidenceSnapshot, +) *LocalEvidenceSnapshot { + t.Helper() + signer := &fakeSigner{id: snap.SenderID()} + payload, err := CanonicalSnapshotBytes(snap) + if err != nil { + t.Fatalf("canonical: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + snap.OperatorSignature = sig + return snap +} + +// newSignedCoordinatorForMember returns an inMemoryCoordinator wired +// for the named member to act as self -- meaning AggregateBundle is +// only callable when that member is the elected coordinator for the +// attempt under test. +func newSignedCoordinatorForMember( + self group.MemberIndex, +) *inMemoryCoordinator { + return NewInMemoryCoordinatorWithSigning( + self, + &fakeSigner{id: self}, + fakeVerifier{}, + ).(*inMemoryCoordinator) +} + +func TestRecordEvidence_RejectsNilSnapshot(t *testing.T) { + c := newSignedCoordinatorForMember(0) + handle, err := c.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + if err := c.RecordEvidence(handle, nil); err == nil { + t.Fatal("expected nil snapshot error") + } +} + +func TestRecordEvidence_RejectsUnknownHandle(t *testing.T) { + c := newSignedCoordinatorForMember(0) + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(1, pinnedContextHash, attempt.Evidence{})) + bogus := AttemptHandle{id: 999} + err := c.RecordEvidence(bogus, snap) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestRecordEvidence_RejectsContextHashMismatch(t *testing.T) { + c := newSignedCoordinatorForMember(0) + handle, err := c.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + // Build a snapshot bound to a *different* context hash. + wrongHash := [attempt.MessageDigestLength]byte{0xff} + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(1, wrongHash, attempt.Evidence{})) + if err := c.RecordEvidence(handle, snap); !errors.Is(err, ErrAttemptContextMismatch) { + t.Fatalf("expected ErrAttemptContextMismatch, got %v", err) + } +} + +func TestRecordEvidence_RejectsBadSignature(t *testing.T) { + c := newSignedCoordinatorForMember(0) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + snap := NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{}) + snap.OperatorSignature = []byte{0xff, 0xee} + err = c.RecordEvidence(handle, snap) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestRecordEvidence_AcceptsValidSnapshotAndIsIdempotent(t *testing.T) { + c := newSignedCoordinatorForMember(0) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("first record: %v", err) + } + // Identical re-submission must be idempotent. + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("idempotent re-record: %v", err) + } +} + +func TestRecordEvidence_RejectsConflict(t *testing.T) { + c := newSignedCoordinatorForMember(0) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + first := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, first); err != nil { + t.Fatalf("first record: %v", err) + } + // Same sender, different evidence -> conflict. + conflicting := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(1, ctx.Hash(), attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{5: 3}, + }), + ) + if err := c.RecordEvidence(handle, conflicting); !errors.Is(err, ErrSnapshotConflict) { + t.Fatalf("expected ErrSnapshotConflict, got %v", err) + } +} + +func TestRecordEvidence_TracksSelfSubmission(t *testing.T) { + const self group.MemberIndex = 3 + c := newSignedCoordinatorForMember(self) + ctx := newTestContext(t) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + selfSnap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(self, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, selfSnap); err != nil { + t.Fatalf("record self: %v", err) + } + record := c.attempts[handle.id] + if record.selfSubmission == nil { + t.Fatal("expected selfSubmission to be set") + } + if record.selfSubmission.SenderID() != self { + t.Fatalf("self submission member mismatch: got %d", record.selfSubmission.SenderID()) + } +} + +func TestAggregateBundle_RejectsNonAggregator(t *testing.T) { + // Two coordinator instances, both begin the same attempt. Only + // the elected one can aggregate. We force the election by + // building a context where SelectCoordinator will pick member 1. + c := NewInMemoryCoordinatorWithSigning(99, &fakeSigner{id: 99}, fakeVerifier{}).(*inMemoryCoordinator) + handle, err := c.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + // member 99 is not in the IncludedSet, so it cannot be the + // elected coordinator. + _, err = c.AggregateBundle(handle) + if !errors.Is(err, ErrNotAggregator) { + t.Fatalf("expected ErrNotAggregator, got %v", err) + } +} + +func TestAggregateBundle_BuildsSignedBundle(t *testing.T) { + // Pick the elected coordinator: run BeginAttempt once with a + // throwaway coordinator instance to discover the elected member, + // then build a real coordinator bound to that self. + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + c := newSignedCoordinatorForMember(elected) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + // Record snapshots from every included member. + for _, m := range ctx.IncludedSet { + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := c.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + if len(bundle.Bundle) != len(ctx.IncludedSet) { + t.Fatalf( + "bundle size: got %d want %d", + len(bundle.Bundle), len(ctx.IncludedSet), + ) + } + for i := 1; i < len(bundle.Bundle); i++ { + if bundle.Bundle[i].SenderIDValue <= bundle.Bundle[i-1].SenderIDValue { + t.Fatalf("bundle not sorted ascending at %d", i) + } + } + if bundle.CoordinatorID() != elected { + t.Fatalf("bundle coordinator id %d != elected %d", bundle.CoordinatorID(), elected) + } + if len(bundle.CoordinatorSignature) == 0 { + t.Fatal("expected coordinator signature to be populated") + } + state, _ := c.State(handle) + if state != AttemptStateTransitioned { + t.Fatalf("expected state Transitioned, got %v", state) + } +} + +func TestAggregateBundle_ProducesDeterministicBundleAcrossOrderings(t *testing.T) { + // Two coordinators aggregate the same evidence in different + // arrival orders. The resulting bundles must be byte-identical + // after JSON marshal. + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + make := func( + t *testing.T, + recordOrder []group.MemberIndex, + ) []byte { + t.Helper() + c := newSignedCoordinatorForMember(elected) + handle, err := c.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + for _, m := range recordOrder { + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := c.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := c.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + data, err := bundle.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + return data + } + ordering1 := []group.MemberIndex{1, 2, 3, 4, 5} + ordering2 := []group.MemberIndex{5, 3, 1, 4, 2} + a := make(t, ordering1) + b := make(t, ordering2) + if !bytes.Equal(a, b) { + t.Fatalf( + "identical evidence in different arrival order produced "+ + "different bundles:\n a=%s\n b=%s", + string(a), string(b), + ) + } +} + +func TestVerifyBundle_AcceptsValidBundle(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, err := aggregator.BeginAttempt(ctx) + if err != nil { + t.Fatalf("aggregator begin: %v", err) + } + for _, m := range ctx.IncludedSet { + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + + // Receiver: a different coordinator instance bound to a + // non-coordinator member that has not submitted its own snapshot. + // The receiver must accept the bundle. + receiverID := pickNonCoordinatorMember(t, ctx.IncludedSet, elected) + receiver := NewInMemoryCoordinatorWithSigning( + receiverID, + &fakeSigner{id: receiverID}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, err := receiver.BeginAttempt(ctx) + if err != nil { + t.Fatalf("receiver begin: %v", err) + } + if err := receiver.VerifyBundle(rh, bundle); err != nil { + t.Fatalf("expected verify success, got %v", err) + } +} + +func TestVerifyBundle_DetectsCensorship(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, err := aggregator.BeginAttempt(ctx) + if err != nil { + t.Fatalf("agg begin: %v", err) + } + // Record snapshots from every member EXCEPT receiverID. + receiverID := pickNonCoordinatorMember(t, ctx.IncludedSet, elected) + for _, m := range ctx.IncludedSet { + if m == receiverID { + continue + } + snap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + ) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record: %v", err) + } + } + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + + // Receiver: bound to receiverID, has submitted its own snapshot, + // but the coordinator chose to censor it. + receiver := NewInMemoryCoordinatorWithSigning( + receiverID, + &fakeSigner{id: receiverID}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, err := receiver.BeginAttempt(ctx) + if err != nil { + t.Fatalf("receiver begin: %v", err) + } + selfSnap := signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(receiverID, ctx.Hash(), attempt.Evidence{}), + ) + if err := receiver.RecordEvidence(rh, selfSnap); err != nil { + t.Fatalf("receiver record self: %v", err) + } + err = receiver.VerifyBundle(rh, bundle) + if !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected, got %v", err) + } +} + +func TestVerifyBundle_DetectsCoordinatorSignatureForgery(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + for _, m := range ctx.IncludedSet { + _ = aggregator.RecordEvidence(handle, signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + )) + } + bundle, _ := aggregator.AggregateBundle(handle) + // Tamper: re-sign the bundle as a different (non-elected) member. + const wrongSigner group.MemberIndex = 99 + bundle.CoordinatorIDValue = uint32(wrongSigner) + payload, _ := CanonicalBundleBytes(bundle) + forged, _ := (&fakeSigner{id: wrongSigner}).Sign(payload) + bundle.CoordinatorSignature = forged + + receiver := NewInMemoryCoordinatorWithSigning( + 7, + &fakeSigner{id: 7}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, _ := receiver.BeginAttempt(ctx) + err := receiver.VerifyBundle(rh, bundle) + if err == nil { + t.Fatal("expected verification failure") + } +} + +func TestVerifyBundle_DetectsSnapshotSignatureForgery(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + for _, m := range ctx.IncludedSet { + _ = aggregator.RecordEvidence(handle, signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + )) + } + bundle, _ := aggregator.AggregateBundle(handle) + + // Tamper: replace one snapshot's signature with garbage. The + // bundle's coordinator signature still validates (since the + // canonical bundle bytes include the snapshot signature, an + // integrated bundle would have detected the change at the + // coordinator-signature layer). For this test we re-sign the + // bundle with the new garbage signature so the bundle-level + // signature appears valid but the snapshot signature does not. + bundle.Bundle[0].OperatorSignature = []byte{0xde, 0xad} + payload, _ := CanonicalBundleBytes(bundle) + resign, _ := (&fakeSigner{id: elected}).Sign(payload) + bundle.CoordinatorSignature = resign + + receiver := NewInMemoryCoordinatorWithSigning( + 7, + &fakeSigner{id: 7}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + rh, _ := receiver.BeginAttempt(ctx) + err := receiver.VerifyBundle(rh, bundle) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestVerifyBundle_RejectsAttemptContextMismatch(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + for _, m := range ctx.IncludedSet { + _ = aggregator.RecordEvidence(handle, signSnapshotForTest( + t, + NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}), + )) + } + bundle, _ := aggregator.AggregateBundle(handle) + + receiver := NewInMemoryCoordinatorWithSigning( + 7, + &fakeSigner{id: 7}, + fakeVerifier{}, + ).(*inMemoryCoordinator) + + // Receiver begins a different attempt context. + wrongCtx, _ := attempt.NewAttemptContext( + "different-session", + "key-group-test", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + rh, _ := receiver.BeginAttempt(wrongCtx) + err := receiver.VerifyBundle(rh, bundle) + if !errors.Is(err, ErrAttemptContextMismatch) { + t.Fatalf("expected ErrAttemptContextMismatch, got %v", err) + } +} + +func TestVerifyBundle_RejectsNilMessage(t *testing.T) { + c := newSignedCoordinatorForMember(7) + handle, _ := c.BeginAttempt(newTestContext(t)) + if err := c.VerifyBundle(handle, nil); err == nil { + t.Fatal("expected error for nil message") + } +} + +func TestVerifyBundle_RejectsUnknownAttempt(t *testing.T) { + c := newSignedCoordinatorForMember(7) + bundle := buildValidTransitionMessage() + bogus := AttemptHandle{id: 999} + if err := c.VerifyBundle(bogus, bundle); !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestCoordinator_ConcurrentRecordAndVerifyAreRaceSafe(t *testing.T) { + scratch := NewInMemoryCoordinator().(*inMemoryCoordinator) + ctx := newTestContext(t) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, _ := aggregator.BeginAttempt(ctx) + var wg sync.WaitGroup + for _, m := range ctx.IncludedSet { + wg.Add(1) + mLocal := m + go func() { + defer wg.Done() + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(mLocal, ctx.Hash(), attempt.Evidence{})) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Errorf("concurrent record %d: %v", mLocal, err) + } + }() + } + wg.Wait() + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate after concurrent records: %v", err) + } + if len(bundle.Bundle) != len(ctx.IncludedSet) { + t.Fatalf( + "bundle size after concurrent records: got %d want %d", + len(bundle.Bundle), len(ctx.IncludedSet), + ) + } +} diff --git a/pkg/frost/roast/coordinator.go b/pkg/frost/roast/coordinator.go new file mode 100644 index 0000000000..5accd12607 --- /dev/null +++ b/pkg/frost/roast/coordinator.go @@ -0,0 +1,41 @@ +package roast + +import ( + "fmt" + "math/rand" + "sort" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// SelectCoordinator deterministically picks a coordinator from the included +// members set for a given attempt. +// +// Selection is pseudo-random but stable across all participants that use the +// same attempt seed and attempt number. +func SelectCoordinator( + includedMembersIndexes []group.MemberIndex, + attemptSeed int64, + attemptNumber uint, +) (group.MemberIndex, error) { + if len(includedMembersIndexes) == 0 { + return 0, fmt.Errorf("cannot select coordinator from empty member set") + } + + members := make([]group.MemberIndex, len(includedMembersIndexes)) + copy(members, includedMembersIndexes) + + // Sort first to make sure selection result is independent from input order. + sort.Slice(members, func(i, j int) bool { + return members[i] < members[j] + }) + + // #nosec G404 (insecure random number source (rand)) + // Coordinator shuffling needs deterministic, not cryptographic randomness. + rng := rand.New(rand.NewSource(attemptSeed + int64(attemptNumber))) + rng.Shuffle(len(members), func(i, j int) { + members[i], members[j] = members[j], members[i] + }) + + return members[0], nil +} diff --git a/pkg/frost/roast/coordinator_state.go b/pkg/frost/roast/coordinator_state.go new file mode 100644 index 0000000000..afbd32792a --- /dev/null +++ b/pkg/frost/roast/coordinator_state.go @@ -0,0 +1,481 @@ +package roast + +import ( + "bytes" + "errors" + "fmt" + "sort" + "sync" + "sync/atomic" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// AttemptState is the phase an attempt is in within the Coordinator +// state machine. The lifecycle is monotonic: +// +// AttemptStatePending -> AttemptStateCollecting -> AttemptStateAggregating +// -> {AttemptStateSucceeded, AttemptStateTransitioned} +// +// AttemptStateSucceeded means the attempt produced a final signature. +// AttemptStateTransitioned means the attempt timed out or hit an +// unrecoverable reject and the coordinator emitted a +// TransitionMessage that drives the next attempt's context. Phase 3.1 +// (this file) introduces the state surface only; later phases drive +// the transitions. +type AttemptState uint8 + +const ( + // AttemptStatePending is the zero value -- not a real state, used + // only as the default-initialised "unknown" sentinel returned with + // ErrUnknownAttempt. + AttemptStatePending AttemptState = iota + // AttemptStateCollecting -- the attempt has been started, the + // included set is fixed, and the coordinator is accepting signed + // evidence snapshots from peers. + AttemptStateCollecting + // AttemptStateAggregating -- the coordinator has stopped + // accepting evidence and is building the TransitionMessage + // bundle. + AttemptStateAggregating + // AttemptStateSucceeded -- the attempt produced a final + // signature; no transition message is needed. + AttemptStateSucceeded + // AttemptStateTransitioned -- the attempt timed out or failed + // and the coordinator has emitted a TransitionMessage; the next + // attempt's context can now be computed by NextAttempt. + AttemptStateTransitioned +) + +func (s AttemptState) String() string { + switch s { + case AttemptStatePending: + return "pending" + case AttemptStateCollecting: + return "collecting" + case AttemptStateAggregating: + return "aggregating" + case AttemptStateSucceeded: + return "succeeded" + case AttemptStateTransitioned: + return "transitioned" + default: + return fmt.Sprintf("unknown(%d)", uint8(s)) + } +} + +// AttemptHandle is the opaque per-attempt identity returned by +// Coordinator.BeginAttempt. Handles are not interchangeable across +// coordinator instances: a handle minted by coordinator A cannot be +// passed to coordinator B. Callers must not mutate handles directly. +type AttemptHandle struct { + id uint64 + contextHash [attempt.MessageDigestLength]byte +} + +// ContextHash returns the canonical AttemptContext.Hash() value bound +// to this handle. Useful for cross-checking a handle against a +// context after the fact. +func (h AttemptHandle) ContextHash() [attempt.MessageDigestLength]byte { + return h.contextHash +} + +// Coordinator is the ROAST coordinator state machine introduced by +// RFC-21 Phase 3. It owns per-attempt state, the deterministic +// participant selection (via the existing SelectCoordinator helper), +// signed-evidence aggregation, transition-message construction, and +// -- in Phase 3.4 -- the NextAttempt policy. +// +// Phase 3.1 introduced BeginAttempt, State, and SelectedCoordinator. +// Phase 3.3 (this commit) adds RecordEvidence, AggregateBundle, and +// VerifyBundle. +// Phase 3.4 will add NextAttempt. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines; production keep-core code paths are network-driven. +type Coordinator interface { + // BeginAttempt initialises tracking for a new attempt with the + // given context. It selects the attempt's coordinator + // deterministically from ctx.IncludedSet via SelectCoordinator + // (with the legacy int64 seed produced by foldAttemptSeed) and + // stores the result on the returned handle. + BeginAttempt(ctx attempt.AttemptContext) (AttemptHandle, error) + // State returns the current AttemptState for the given handle. + // Returns ErrUnknownAttempt if the handle was not produced by + // this Coordinator instance. + State(handle AttemptHandle) (AttemptState, error) + // SelectedCoordinator returns the member elected as coordinator + // for the attempt identified by the handle. Returns + // ErrUnknownAttempt if the handle is not tracked. + SelectedCoordinator(handle AttemptHandle) (group.MemberIndex, error) + // RecordEvidence stores a peer's signed LocalEvidenceSnapshot + // against the named attempt. The snapshot is validated for + // structural correctness, its OperatorSignature is verified + // against the configured SignatureVerifier, and its + // AttemptContextHash is checked to match the handle's bound + // context. First-write-wins / equal-or-reject semantics apply: + // a peer that re-submits the same byte-identical snapshot is + // idempotent; a peer that mutates its snapshot returns an error + // without overwriting the originally accepted one. + RecordEvidence(handle AttemptHandle, snapshot *LocalEvidenceSnapshot) error + // AggregateBundle is called by the elected coordinator's node + // to produce a TransitionMessage from the accumulated evidence + // snapshots. The bundle is sorted ascending by SenderID, signed + // with the coordinator's Signer, and the attempt state is + // transitioned to AttemptStateAggregating then + // AttemptStateTransitioned. + // + // Returns ErrNotAggregator if the caller is not the elected + // coordinator for the attempt (the Coordinator's selfMember + // must equal SelectedCoordinator(handle)). + AggregateBundle(handle AttemptHandle) (*TransitionMessage, error) + // VerifyBundle is called by every receiver of a + // TransitionMessage. It validates the structural invariants of + // the bundle, verifies the coordinator-level signature against + // the attempt's elected coordinator, verifies each contained + // snapshot's operator signature, and -- if the receiver has + // already submitted its own snapshot via RecordEvidence with + // the local Signer applied -- verifies that the receiver's own + // snapshot is present and byte-identical in the bundle + // (censorship detection). + // + // Returns ErrCensorshipDetected when the receiver's own + // submitted snapshot is missing or mutated. Returns + // ErrSignatureInvalid when any signature fails verification. + VerifyBundle(handle AttemptHandle, msg *TransitionMessage) error + // NextAttempt computes the deterministic next AttemptContext + // from a verified TransitionMessage. Callers MUST call + // VerifyBundle before NextAttempt; NextAttempt does not + // re-verify signatures. + // + // threshold is the FROST signing threshold t for the key group; + // it is constant across attempts within a session. A threshold + // of zero disables the infeasibility check (test seam). + // + // dkgGroupPublicKey is the DKG-validated group public key from + // the FFI signer material (RFC-21 Decision 2). It is passed + // here so two honest signers derive the same AttemptSeed for + // the next attempt. + // + // Returns ErrAttemptInfeasible when the next IncludedSet would + // drop below threshold. + NextAttempt( + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, + ) (attempt.AttemptContext, error) +} + +// ErrNotAggregator is returned by AggregateBundle when the caller +// is not the elected coordinator for the named attempt. +var ErrNotAggregator = errors.New( + "coordinator: caller is not the elected coordinator for this attempt", +) + +// ErrAttemptStateInvalid is returned when an operation is requested +// against an attempt in a state that does not permit it (e.g. +// AggregateBundle on an attempt already transitioned, or +// RecordEvidence on an attempt past Collecting). +var ErrAttemptStateInvalid = errors.New("coordinator: attempt state does not permit operation") + +// ErrAttemptContextMismatch is returned when a snapshot's +// AttemptContextHash does not match the handle's bound context. +var ErrAttemptContextMismatch = errors.New("coordinator: snapshot attempt context hash does not match attempt") + +// ErrSnapshotConflict is returned by RecordEvidence when a peer +// re-submits a snapshot whose canonical bytes differ from the +// previously-accepted snapshot for that peer in this attempt. The +// originally accepted snapshot is retained; the new submission is +// rejected (first-write-wins). +var ErrSnapshotConflict = errors.New("coordinator: snapshot conflicts with previously recorded one (first-write-wins)") + +// ErrUnknownAttempt indicates an AttemptHandle does not correspond to +// any attempt tracked by this Coordinator. Either the handle was +// minted by a different coordinator instance, or the attempt has +// been pruned. +var ErrUnknownAttempt = errors.New("coordinator: unknown attempt handle") + +// NewInMemoryCoordinator returns a Coordinator that tracks attempts +// in-process with no operator-key signing wired in (NoOpSigner + +// NoOpSignatureVerifier). Suitable for tests that exercise only the +// structural state-machine surface; bundle verification will accept +// any signature. +// +// Production Phase-4 callers should use +// NewInMemoryCoordinatorWithSigning to inject the node's real +// operator-key signer and the network's member-key-resolving +// verifier. +func NewInMemoryCoordinator() Coordinator { + return NewInMemoryCoordinatorWithSigning( + 0, + NoOpSigner(), + NoOpSignatureVerifier(), + ) +} + +// NewInMemoryCoordinatorWithSigning returns an in-memory Coordinator +// bound to the node's own member index, the node's operator-key +// Signer, and a SignatureVerifier capable of resolving every member's +// operator key. selfMember = 0 disables the censorship-detection +// check in VerifyBundle (Phase 3.3 default for unit tests; Phase 4 +// always supplies a non-zero value). +func NewInMemoryCoordinatorWithSigning( + selfMember group.MemberIndex, + signer Signer, + verifier SignatureVerifier, +) Coordinator { + return &inMemoryCoordinator{ + attempts: map[uint64]*attemptRecord{}, + selfMember: selfMember, + signer: signer, + verifier: verifier, + } +} + +type attemptRecord struct { + handle AttemptHandle + context attempt.AttemptContext + coordinator group.MemberIndex + state AttemptState + snapshots map[group.MemberIndex]*LocalEvidenceSnapshot + selfSubmission *LocalEvidenceSnapshot +} + +type inMemoryCoordinator struct { + mu sync.Mutex + nextID atomic.Uint64 + attempts map[uint64]*attemptRecord + selfMember group.MemberIndex + signer Signer + verifier SignatureVerifier +} + +func (c *inMemoryCoordinator) BeginAttempt( + ctx attempt.AttemptContext, +) (AttemptHandle, error) { + if len(ctx.IncludedSet) == 0 { + return AttemptHandle{}, fmt.Errorf( + "coordinator: cannot begin attempt with empty included set", + ) + } + coord, err := SelectCoordinator( + ctx.IncludedSet, + foldAttemptSeed(ctx.AttemptSeed), + uint(ctx.AttemptNumber), + ) + if err != nil { + return AttemptHandle{}, fmt.Errorf( + "coordinator: selection failed: %w", + err, + ) + } + handle := AttemptHandle{ + id: c.nextID.Add(1), + contextHash: ctx.Hash(), + } + record := &attemptRecord{ + handle: handle, + context: ctx, + coordinator: coord, + state: AttemptStateCollecting, + snapshots: map[group.MemberIndex]*LocalEvidenceSnapshot{}, + } + c.mu.Lock() + defer c.mu.Unlock() + c.attempts[handle.id] = record + return handle, nil +} + +func (c *inMemoryCoordinator) State( + handle AttemptHandle, +) (AttemptState, error) { + c.mu.Lock() + defer c.mu.Unlock() + record, ok := c.attempts[handle.id] + if !ok { + return AttemptStatePending, ErrUnknownAttempt + } + return record.state, nil +} + +func (c *inMemoryCoordinator) SelectedCoordinator( + handle AttemptHandle, +) (group.MemberIndex, error) { + c.mu.Lock() + defer c.mu.Unlock() + record, ok := c.attempts[handle.id] + if !ok { + return 0, ErrUnknownAttempt + } + return record.coordinator, nil +} + +func (c *inMemoryCoordinator) RecordEvidence( + handle AttemptHandle, + snapshot *LocalEvidenceSnapshot, +) error { + if snapshot == nil { + return errors.New("coordinator: snapshot is nil") + } + if err := snapshot.Validate(); err != nil { + return fmt.Errorf("coordinator: snapshot invalid: %w", err) + } + if err := verifySnapshotSignature(c.verifier, snapshot); err != nil { + return fmt.Errorf("coordinator: %w", err) + } + + c.mu.Lock() + defer c.mu.Unlock() + record, ok := c.attempts[handle.id] + if !ok { + return ErrUnknownAttempt + } + if record.state != AttemptStateCollecting { + return fmt.Errorf( + "%w: state is %v, want %v", + ErrAttemptStateInvalid, + record.state, + AttemptStateCollecting, + ) + } + if !bytes.Equal( + snapshot.AttemptContextHash, + record.handle.contextHash[:], + ) { + return ErrAttemptContextMismatch + } + + if existing, present := record.snapshots[snapshot.SenderID()]; present { + existingBytes, err := CanonicalSnapshotBytes(existing) + if err != nil { + return fmt.Errorf("coordinator: canonical existing: %w", err) + } + newBytes, err := CanonicalSnapshotBytes(snapshot) + if err != nil { + return fmt.Errorf("coordinator: canonical new: %w", err) + } + if !bytes.Equal(existingBytes, newBytes) || + !bytes.Equal(existing.OperatorSignature, snapshot.OperatorSignature) { + return ErrSnapshotConflict + } + // Identical re-submission: idempotent no-op. + return nil + } + record.snapshots[snapshot.SenderID()] = snapshot + if c.selfMember != 0 && snapshot.SenderID() == c.selfMember { + record.selfSubmission = snapshot + } + return nil +} + +func (c *inMemoryCoordinator) AggregateBundle( + handle AttemptHandle, +) (*TransitionMessage, error) { + c.mu.Lock() + record, ok := c.attempts[handle.id] + if !ok { + c.mu.Unlock() + return nil, ErrUnknownAttempt + } + if c.selfMember == 0 || record.coordinator != c.selfMember { + c.mu.Unlock() + return nil, ErrNotAggregator + } + if record.state != AttemptStateCollecting { + c.mu.Unlock() + return nil, fmt.Errorf( + "%w: state is %v, want %v", + ErrAttemptStateInvalid, + record.state, + AttemptStateCollecting, + ) + } + + senders := make([]group.MemberIndex, 0, len(record.snapshots)) + for s := range record.snapshots { + senders = append(senders, s) + } + sort.Slice(senders, func(i, j int) bool { return senders[i] < senders[j] }) + + bundle := make([]LocalEvidenceSnapshot, 0, len(senders)) + for _, s := range senders { + bundle = append(bundle, *record.snapshots[s]) + } + + record.state = AttemptStateAggregating + hash := record.handle.contextHash + coord := record.coordinator + c.mu.Unlock() + + msg := &TransitionMessage{ + AttemptContextHash: append([]byte{}, hash[:]...), + CoordinatorIDValue: uint32(coord), + Bundle: bundle, + } + payload, err := CanonicalBundleBytes(msg) + if err != nil { + c.markTransitionedLocked(handle.id) + return nil, fmt.Errorf("coordinator: canonical bundle: %w", err) + } + sig, err := c.signer.Sign(payload) + if err != nil { + c.markTransitionedLocked(handle.id) + return nil, fmt.Errorf("coordinator: sign bundle: %w", err) + } + msg.CoordinatorSignature = sig + if err := msg.Validate(); err != nil { + c.markTransitionedLocked(handle.id) + return nil, fmt.Errorf("coordinator: aggregated bundle invalid: %w", err) + } + c.markTransitionedLocked(handle.id) + return msg, nil +} + +func (c *inMemoryCoordinator) markTransitionedLocked(id uint64) { + c.mu.Lock() + defer c.mu.Unlock() + if record, ok := c.attempts[id]; ok { + record.state = AttemptStateTransitioned + } +} + +func (c *inMemoryCoordinator) VerifyBundle( + handle AttemptHandle, + msg *TransitionMessage, +) error { + if msg == nil { + return errors.New("coordinator: transition message is nil") + } + if err := msg.Validate(); err != nil { + return fmt.Errorf("coordinator: transition message invalid: %w", err) + } + + c.mu.Lock() + record, ok := c.attempts[handle.id] + if !ok { + c.mu.Unlock() + return ErrUnknownAttempt + } + expectedCoordinator := record.coordinator + expectedHash := record.handle.contextHash + selfSubmission := record.selfSubmission + c.mu.Unlock() + + if !bytes.Equal(msg.AttemptContextHash, expectedHash[:]) { + return ErrAttemptContextMismatch + } + if err := verifyBundleSignature(c.verifier, msg, expectedCoordinator); err != nil { + return fmt.Errorf("coordinator: %w", err) + } + for i := range msg.Bundle { + if err := verifySnapshotSignature(c.verifier, &msg.Bundle[i]); err != nil { + return fmt.Errorf("coordinator: bundle[%d]: %w", i, err) + } + } + if err := verifyOwnObservationsPresent(msg, c.selfMember, selfSubmission); err != nil { + return err + } + return nil +} diff --git a/pkg/frost/roast/coordinator_state_test.go b/pkg/frost/roast/coordinator_state_test.go new file mode 100644 index 0000000000..0fdc0afb5c --- /dev/null +++ b/pkg/frost/roast/coordinator_state_test.go @@ -0,0 +1,263 @@ +package roast + +import ( + "errors" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newTestContext(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "session-test", + "key-group-test", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("test context: %v", err) + } + return ctx +} + +func TestBeginAttempt_ReturnsHandleWithMatchingContextHash(t *testing.T) { + coord := NewInMemoryCoordinator() + ctx := newTestContext(t) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if handle.ContextHash() != ctx.Hash() { + t.Fatalf( + "handle hash mismatch: got %x want %x", + handle.ContextHash(), ctx.Hash(), + ) + } +} + +func TestBeginAttempt_HandlesAreDistinctAcrossAttempts(t *testing.T) { + coord := NewInMemoryCoordinator() + a, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("first begin: %v", err) + } + b, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("second begin: %v", err) + } + if a.id == b.id { + t.Fatalf("two attempts shared handle id %d", a.id) + } +} + +func TestBeginAttempt_RejectsEmptyIncludedSet(t *testing.T) { + coord := NewInMemoryCoordinator() + // We bypass NewAttemptContext (which forbids empty included set) + // to assert BeginAttempt's defence-in-depth check. + ctx := attempt.AttemptContext{} + _, err := coord.BeginAttempt(ctx) + if err == nil { + t.Fatal("expected error on empty included set") + } +} + +func TestState_ReturnsCollectingAfterBegin(t *testing.T) { + coord := NewInMemoryCoordinator() + handle, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Fatalf("begin: %v", err) + } + state, err := coord.State(handle) + if err != nil { + t.Fatalf("state: %v", err) + } + if state != AttemptStateCollecting { + t.Fatalf( + "expected collecting, got %v", + state, + ) + } +} + +func TestState_UnknownHandleReturnsSentinel(t *testing.T) { + coord := NewInMemoryCoordinator() + bogus := AttemptHandle{id: 999} + state, err := coord.State(bogus) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } + if state != AttemptStatePending { + t.Fatalf("expected pending sentinel, got %v", state) + } +} + +func TestSelectedCoordinator_ReturnsMemberFromIncludedSet(t *testing.T) { + coord := NewInMemoryCoordinator() + ctx := newTestContext(t) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + got, err := coord.SelectedCoordinator(handle) + if err != nil { + t.Fatalf("selected coordinator: %v", err) + } + found := false + for _, m := range ctx.IncludedSet { + if m == got { + found = true + break + } + } + if !found { + t.Fatalf( + "selected coordinator %d not in included set %v", + got, ctx.IncludedSet, + ) + } +} + +func TestSelectedCoordinator_IsDeterministicForSameContext(t *testing.T) { + a := NewInMemoryCoordinator() + b := NewInMemoryCoordinator() + ctx := newTestContext(t) + ha, err := a.BeginAttempt(ctx) + if err != nil { + t.Fatalf("a.begin: %v", err) + } + hb, err := b.BeginAttempt(ctx) + if err != nil { + t.Fatalf("b.begin: %v", err) + } + ca, err := a.SelectedCoordinator(ha) + if err != nil { + t.Fatalf("a.selected: %v", err) + } + cb, err := b.SelectedCoordinator(hb) + if err != nil { + t.Fatalf("b.selected: %v", err) + } + if ca != cb { + t.Fatalf( + "two coordinators disagreed on same context: %d != %d", + ca, cb, + ) + } +} + +func TestSelectedCoordinator_DifferentAttemptNumbersCanProduceDifferentLeaders(t *testing.T) { + coord := NewInMemoryCoordinator() + build := func(attemptNumber uint32) attempt.AttemptContext { + ctx, err := attempt.NewAttemptContext( + "session-test", + "key-group-test", + []byte{0x01}, + [attempt.MessageDigestLength]byte{0x42}, + attemptNumber, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("build ctx: %v", err) + } + return ctx + } + + // Sweep a few attempt numbers; verify the elected coordinator is + // not always the same member -- otherwise the retry-rotation + // property of ROAST does not hold. + seen := map[group.MemberIndex]struct{}{} + for n := uint32(0); n < 16; n++ { + ctx := build(n) + handle, err := coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin n=%d: %v", n, err) + } + c, err := coord.SelectedCoordinator(handle) + if err != nil { + t.Fatalf("selected n=%d: %v", n, err) + } + seen[c] = struct{}{} + } + if len(seen) < 2 { + t.Fatalf( + "coordinator rotation broken: 16 different attempts all "+ + "elected the same leader; seen=%v", + seen, + ) + } +} + +func TestSelectedCoordinator_UnknownHandleReturnsSentinel(t *testing.T) { + coord := NewInMemoryCoordinator() + bogus := AttemptHandle{id: 999} + got, err := coord.SelectedCoordinator(bogus) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } + if got != 0 { + t.Fatalf("expected zero member index, got %d", got) + } +} + +func TestInMemoryCoordinator_ConcurrentBeginAttemptsAreRaceSafe(t *testing.T) { + const numGoroutines = 16 + const beginsPerGoroutine = 50 + + coord := NewInMemoryCoordinator() + var wg sync.WaitGroup + handles := make(chan AttemptHandle, numGoroutines*beginsPerGoroutine) + + for g := 0; g < numGoroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < beginsPerGoroutine; i++ { + h, err := coord.BeginAttempt(newTestContext(t)) + if err != nil { + t.Errorf("concurrent begin: %v", err) + return + } + handles <- h + } + }() + } + wg.Wait() + close(handles) + + ids := map[uint64]struct{}{} + for h := range handles { + if _, dup := ids[h.id]; dup { + t.Fatalf("duplicate handle id %d under concurrency", h.id) + } + ids[h.id] = struct{}{} + } + if len(ids) != numGoroutines*beginsPerGoroutine { + t.Fatalf( + "expected %d unique handles, got %d", + numGoroutines*beginsPerGoroutine, len(ids), + ) + } +} + +func TestAttemptState_String(t *testing.T) { + cases := map[AttemptState]string{ + AttemptStatePending: "pending", + AttemptStateCollecting: "collecting", + AttemptStateAggregating: "aggregating", + AttemptStateSucceeded: "succeeded", + AttemptStateTransitioned: "transitioned", + AttemptState(99): "unknown(99)", + } + for state, want := range cases { + if got := state.String(); got != want { + t.Errorf("State %d: got %q want %q", state, got, want) + } + } +} diff --git a/pkg/frost/roast/coordinator_test.go b/pkg/frost/roast/coordinator_test.go new file mode 100644 index 0000000000..0847685de6 --- /dev/null +++ b/pkg/frost/roast/coordinator_test.go @@ -0,0 +1,111 @@ +package roast + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestSelectCoordinator_EmptySet(t *testing.T) { + _, err := SelectCoordinator([]group.MemberIndex{}, 100, 1) + if err == nil { + t.Fatal("expected coordinator selection error") + } +} + +func TestSelectCoordinator_Deterministic(t *testing.T) { + members := []group.MemberIndex{4, 1, 3, 2} + + first, err := SelectCoordinator(members, 12345, 2) + if err != nil { + t.Fatalf("selection failed: [%v]", err) + } + + for i := 0; i < 20; i++ { + again, err := SelectCoordinator(members, 12345, 2) + if err != nil { + t.Fatalf("selection failed on run [%d]: [%v]", i, err) + } + + if again != first { + t.Fatalf( + "non-deterministic coordinator\nexpected: [%v]\nactual: [%v]", + first, + again, + ) + } + } +} + +func TestSelectCoordinator_InputOrderIndependent(t *testing.T) { + left := []group.MemberIndex{1, 2, 3, 4, 5, 6} + right := []group.MemberIndex{6, 1, 5, 2, 4, 3} + + leftCoordinator, err := SelectCoordinator(left, 333, 4) + if err != nil { + t.Fatalf("left selection failed: [%v]", err) + } + + rightCoordinator, err := SelectCoordinator(right, 333, 4) + if err != nil { + t.Fatalf("right selection failed: [%v]", err) + } + + if leftCoordinator != rightCoordinator { + t.Fatalf( + "input order should not matter\nleft: [%v]\nright: [%v]", + leftCoordinator, + rightCoordinator, + ) + } +} + +func TestSelectCoordinator_AffectedByAttemptNumber(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5, 6} + first, err := SelectCoordinator(members, 777, 1) + if err != nil { + t.Fatalf("selection failed: [%v]", err) + } + + differentObserved := false + for attempt := uint(2); attempt <= 20; attempt++ { + candidate, err := SelectCoordinator(members, 777, attempt) + if err != nil { + t.Fatalf("selection failed for attempt [%d]: [%v]", attempt, err) + } + + if candidate != first { + differentObserved = true + break + } + } + + if !differentObserved { + t.Fatal("coordinator did not change for any attempt number") + } +} + +func TestSelectCoordinator_AffectedBySeed(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5, 6} + first, err := SelectCoordinator(members, 1000, 2) + if err != nil { + t.Fatalf("selection failed: [%v]", err) + } + + differentObserved := false + for seed := int64(1001); seed <= 1030; seed++ { + candidate, err := SelectCoordinator(members, seed, 2) + if err != nil { + t.Fatalf("selection failed for seed [%d]: [%v]", seed, err) + } + + if candidate != first { + differentObserved = true + break + } + } + + if !differentObserved { + t.Fatal("coordinator did not change for any seed") + } +} diff --git a/pkg/frost/roast/multi_coordinator_soak_test.go b/pkg/frost/roast/multi_coordinator_soak_test.go new file mode 100644 index 0000000000..cd7dbc9b95 --- /dev/null +++ b/pkg/frost/roast/multi_coordinator_soak_test.go @@ -0,0 +1,430 @@ +package roast + +import ( + "bytes" + "crypto/sha256" + "errors" + "sort" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// The soak harness models the production deployment: every signer +// runs its own Coordinator instance bound to its own selfMember, +// shares the same signer/verifier scheme (here a deterministic +// SHA-256 stand-in), and must compute byte-identical next contexts +// given the same verified TransitionMessage. +// +// The harness exercises RFC-21 Layer A (overflow exclusion), Layer +// B (silence parking + reinstatement), and the policy's +// infeasibility floor under synthetic fault injection. The receive +// loops are bypassed -- they are unit-tested elsewhere; what the +// soak harness adds is the multi-instance-agreement property. + +// soakSigner produces SHA-256(member || payload) signatures. The +// matching soakVerifier accepts any signature byte-identical to +// the recomputation, so cross-instance verification works without +// real crypto. +type soakSigner struct { + id group.MemberIndex +} + +func (s *soakSigner) Sign(payload []byte) ([]byte, error) { + h := sha256.New() + h.Write([]byte{byte(s.id)}) + h.Write(payload) + return h.Sum(nil), nil +} + +type soakVerifier struct{} + +func (soakVerifier) Verify(payload, signature []byte, signer group.MemberIndex) error { + h := sha256.New() + h.Write([]byte{byte(signer)}) + h.Write(payload) + want := h.Sum(nil) + if !bytes.Equal(want, signature) { + return errors.New("soakVerifier: signature does not match recomputation") + } + return nil +} + +// soakNode bundles one signer's Coordinator instance, its self +// signer, and the snapshot it submits each attempt. +type soakNode struct { + self group.MemberIndex + coord Coordinator + signer *soakSigner +} + +// newSoakHarness initialises N coordinator instances bound to +// member indices 1..N, ready to BeginAttempt against a shared +// AttemptContext. Returns the nodes plus a deterministic +// shared-state baseline attempt context. +func newSoakHarness( + t *testing.T, + members []group.MemberIndex, +) []*soakNode { + t.Helper() + nodes := make([]*soakNode, 0, len(members)) + for _, m := range members { + signer := &soakSigner{id: m} + node := &soakNode{ + self: m, + coord: NewInMemoryCoordinatorWithSigning(m, signer, soakVerifier{}), + signer: signer, + } + nodes = append(nodes, node) + } + return nodes +} + +// soakAttempt drives a full attempt across every node: +// +// 1. Every node calls BeginAttempt with the shared context. +// 2. Every node produces a signed snapshot per the fault map +// (silent members produce nil; overflowing members produce +// snapshots with overflow events). +// 3. Every node receives every other node's snapshot via +// RecordEvidence. +// 4. The elected coordinator's node calls AggregateBundle. +// 5. Every non-coordinator node calls VerifyBundle. +// 6. Every node calls NextAttempt against the same verified +// bundle. +// +// Returns the next AttemptContext computed by every node (all must +// be byte-identical) and the elected coordinator's identity for +// the *current* attempt. +// +// silenceFor and overflowFor are maps that let the test inject +// faults. overflowFor[observer] = [senders the observer reports +// having overflowed]. +func soakAttempt( + t *testing.T, + nodes []*soakNode, + ctx attempt.AttemptContext, + silenceFor map[group.MemberIndex]bool, + overflowFor map[group.MemberIndex][]group.MemberIndex, + threshold uint, +) (attempt.AttemptContext, group.MemberIndex) { + t.Helper() + + type beginResult struct { + node *soakNode + handle AttemptHandle + } + begins := make([]beginResult, 0, len(nodes)) + for _, n := range nodes { + h, err := n.coord.BeginAttempt(ctx) + if err != nil { + t.Fatalf("node %d BeginAttempt: %v", n.self, err) + } + begins = append(begins, beginResult{node: n, handle: h}) + } + + // Elect coordinator: each node has the same SelectCoordinator + // result for this context, so it doesn't matter which node we + // ask. Use begins[0]. + elected, err := begins[0].node.coord.SelectedCoordinator(begins[0].handle) + if err != nil { + t.Fatalf("SelectedCoordinator: %v", err) + } + + // Each node produces a snapshot unless silent. + type signedSnap struct { + from group.MemberIndex + snapshot *LocalEvidenceSnapshot + } + snaps := make([]signedSnap, 0, len(nodes)) + for _, n := range nodes { + if silenceFor[n.self] { + continue + } + evidence := attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{}, + } + for _, sender := range overflowFor[n.self] { + evidence.Overflows[sender]++ + } + snap := NewLocalEvidenceSnapshot(n.self, ctx.Hash(), evidence) + payload, _ := CanonicalSnapshotBytes(snap) + sig, _ := n.signer.Sign(payload) + snap.OperatorSignature = sig + snaps = append(snaps, signedSnap{from: n.self, snapshot: snap}) + } + + // Every node receives every snapshot. + for _, b := range begins { + for _, s := range snaps { + if err := b.node.coord.RecordEvidence(b.handle, s.snapshot); err != nil { + t.Fatalf( + "node %d RecordEvidence from %d: %v", + b.node.self, s.from, err, + ) + } + } + } + + // Find the elected coordinator's node and aggregate. + var aggregator beginResult + for _, b := range begins { + if b.node.self == elected { + aggregator = b + break + } + } + if aggregator.node == nil { + t.Fatalf("elected coordinator %d not in nodes", elected) + } + bundle, err := aggregator.node.coord.AggregateBundle(aggregator.handle) + if err != nil { + t.Fatalf("AggregateBundle on elected node %d: %v", elected, err) + } + + // Every non-coordinator node verifies the bundle. + for _, b := range begins { + if b.node.self == elected { + continue + } + if err := b.node.coord.VerifyBundle(b.handle, bundle); err != nil { + t.Fatalf("node %d VerifyBundle: %v", b.node.self, err) + } + } + + // Every node computes NextAttempt. + dkgPub := []byte{0xab, 0xcd, 0xef} + nextContexts := make([]attempt.AttemptContext, 0, len(nodes)) + for _, b := range begins { + next, err := b.node.coord.NextAttempt( + b.handle, + bundle, + threshold, + dkgPub, + ) + if err != nil { + t.Fatalf("node %d NextAttempt: %v", b.node.self, err) + } + nextContexts = append(nextContexts, next) + } + + // All nodes must produce byte-identical next contexts. + for i := 1; i < len(nextContexts); i++ { + if nextContexts[i].Hash() != nextContexts[0].Hash() { + t.Fatalf( + "multi-instance agreement violated: node 0 hash %x, node %d hash %x", + nextContexts[0].Hash(), + i, + nextContexts[i].Hash(), + ) + } + } + + return nextContexts[0], elected +} + +func soakStartingContext( + t *testing.T, + included []group.MemberIndex, +) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "soak-session", + "soak-key-group", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x99}, + 0, + included, + nil, + ) + if err != nil { + t.Fatalf("starting ctx: %v", err) + } + return ctx +} + +func TestSoak_CleanAttemptPreservesIncludedSet(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + next, _ := soakAttempt(t, nodes, prev, nil, nil, 3) + + if len(next.IncludedSet) != len(members) { + t.Fatalf( + "clean attempt must preserve IncludedSet size; got %d want %d", + len(next.IncludedSet), len(members), + ) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("clean attempt must not exclude anyone; got %v", next.ExcludedSet) + } + if len(next.TransientlyParked) != 0 { + t.Fatalf("clean attempt must not park anyone; got %v", next.TransientlyParked) + } +} + +func TestSoak_OverflowEvidenceExcludesPermanently(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + // Four observers report 1 overflow each against member 3. + // Total 4 = OverflowExclusionThreshold. + overflow := map[group.MemberIndex][]group.MemberIndex{ + 1: {3}, + 2: {3}, + 4: {3}, + 5: {3}, + } + next, _ := soakAttempt(t, nodes, prev, nil, overflow, 3) + + if !containsMember(next.ExcludedSet, 3) { + t.Fatalf("member 3 must be excluded; got %v", next.ExcludedSet) + } + if containsMember(next.IncludedSet, 3) { + t.Fatal("member 3 must not be in next IncludedSet") + } +} + +func TestSoak_SilenceParksTransiently(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + silence := map[group.MemberIndex]bool{3: true} + next, _ := soakAttempt(t, nodes, prev, silence, nil, 3) + + if !containsMember(next.TransientlyParked, 3) { + t.Fatalf("silent member 3 must be parked; got %v", next.TransientlyParked) + } + if containsMember(next.ExcludedSet, 3) { + t.Fatal("silent member 3 must not be permanently excluded") + } + if containsMember(next.IncludedSet, 3) { + t.Fatal("silent member 3 must not be in next IncludedSet") + } +} + +func TestSoak_ParkedMemberIsReinstatedNextAttempt(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + // Attempt N: member 3 silent → parked at N+1. + silenceN := map[group.MemberIndex]bool{3: true} + contextN1, _ := soakAttempt(t, nodes, prev, silenceN, nil, 3) + if !containsMember(contextN1.TransientlyParked, 3) { + t.Fatalf("setup: N+1 must park member 3; got %v", contextN1.TransientlyParked) + } + + // Attempt N+1: member 3 cannot submit (parked). Other 4 members + // do submit. Need a fresh harness because each node's + // Coordinator already transitioned its previous attempt. + nextNodes := newSoakHarness(t, members) + silenceN1 := map[group.MemberIndex]bool{ + 3: true, // parked by design, cannot submit + } + contextN2, _ := soakAttempt(t, nextNodes, contextN1, silenceN1, nil, 3) + + if !containsMember(contextN2.IncludedSet, 3) { + t.Fatalf("member 3 must be reinstated at N+2; got %v", contextN2.IncludedSet) + } + if containsMember(contextN2.TransientlyParked, 3) { + t.Fatal("member 3 must not be re-parked at N+2") + } + if containsMember(contextN2.ExcludedSet, 3) { + t.Fatal("member 3 must not be permanently excluded at N+2") + } +} + +func TestSoak_InfeasibilityWhenBelowThreshold(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + nodes := newSoakHarness(t, members) + prev := soakStartingContext(t, members) + + // Threshold = 5 (all members required). Silence two members. + // Next attempt's IncludedSet would be 3 (= 5 - 2 silenced), below 5. + // NextAttempt must return ErrAttemptInfeasible. + silence := map[group.MemberIndex]bool{ + 4: true, + 5: true, + } + // Build the bundle manually because soakAttempt panics on + // NextAttempt error. Walk the same steps but skip the post- + // aggregate verify on infeasibility. + type beginResult struct { + node *soakNode + handle AttemptHandle + } + begins := make([]beginResult, 0, len(nodes)) + for _, n := range nodes { + h, _ := n.coord.BeginAttempt(prev) + begins = append(begins, beginResult{node: n, handle: h}) + } + for _, n := range nodes { + if silence[n.self] { + continue + } + snap := NewLocalEvidenceSnapshot(n.self, prev.Hash(), attempt.Evidence{}) + payload, _ := CanonicalSnapshotBytes(snap) + sig, _ := n.signer.Sign(payload) + snap.OperatorSignature = sig + for _, b := range begins { + _ = b.node.coord.RecordEvidence(b.handle, snap) + } + } + elected, _ := begins[0].node.coord.SelectedCoordinator(begins[0].handle) + var aggregator beginResult + for _, b := range begins { + if b.node.self == elected { + aggregator = b + break + } + } + bundle, _ := aggregator.node.coord.AggregateBundle(aggregator.handle) + + // Verify each non-coordinator's NextAttempt returns infeasible. + for _, b := range begins { + _, err := b.node.coord.NextAttempt(b.handle, bundle, 5, []byte{0x01}) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf( + "node %d NextAttempt: expected ErrAttemptInfeasible; got %v", + b.node.self, err, + ) + } + } +} + +func TestSoak_OriginalSignerSetIsPreservedAcrossThreeTransitions(t *testing.T) { + members := []group.MemberIndex{1, 2, 3, 4, 5} + prev := soakStartingContext(t, members) + + // Three attempts back-to-back, with fresh harnesses each + // (real signers run one attempt per Coordinator instance). + for i := 0; i < 3; i++ { + nodes := newSoakHarness(t, members) + next, _ := soakAttempt(t, nodes, prev, nil, nil, 3) + if sz := len(next.IncludedSet) + len(next.ExcludedSet) + len(next.TransientlyParked); sz != len(members) { + t.Fatalf( + "attempt %d: |Inc|+|Exc|+|Park| = %d, want %d", + i, sz, len(members), + ) + } + prev = next + } +} + +func containsMember(slice []group.MemberIndex, target group.MemberIndex) bool { + for _, m := range slice { + if m == target { + return true + } + } + return false +} + +// silence the unused-import warning for sort if no test references +// it directly. +var _ = sort.Slice diff --git a/pkg/frost/roast/next_attempt.go b/pkg/frost/roast/next_attempt.go new file mode 100644 index 0000000000..4f896c6b23 --- /dev/null +++ b/pkg/frost/roast/next_attempt.go @@ -0,0 +1,331 @@ +package roast + +import ( + "errors" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// OverflowExclusionThreshold is the per-sender overflow-count +// threshold above which the NextAttempt policy permanently excludes +// the sender (transport-blamable). Matches the constant documented in +// RFC-21 Layer B. +const OverflowExclusionThreshold uint = 4 + +// RejectExclusionThreshold is the per-sender summed-reject-count +// threshold above which the NextAttempt policy permanently +// excludes the sender (validation-blamable). RFC-21 Layer B +// specifies any non-transport reject as sufficient cause, so the +// constant is 1. Reasons are not differentiated by the policy +// today; every reject category counts equally. +const RejectExclusionThreshold uint = 1 + +// ConflictExclusionThreshold is the per-sender summed-conflict- +// count threshold above which the NextAttempt policy permanently +// excludes the sender (equivocation-blamable). A single +// first-write-wins conflict is sufficient evidence: an honest +// peer retransmitting a contribution sends byte-identical bytes, +// so a conflict implies the peer changed its claim mid-attempt. +const ConflictExclusionThreshold uint = 1 + +// ErrAttemptInfeasible is returned by NextAttempt when the next +// attempt's IncludedSet would drop below the signing threshold t and +// the session can no longer make progress with the original signer +// set. Callers must surface this to the application layer: the +// session is permanently failed. +var ErrAttemptInfeasible = errors.New( + "coordinator: next attempt is infeasible -- included set below threshold", +) + +// NextAttempt computes the deterministic next attempt context from a +// verified TransitionMessage. It is a pure function of +// (previous AttemptContext, bundle, threshold): two honest signers +// fed the same inputs produce byte-identical outputs, so the +// signing-group state machine remains in agreement across the +// network. +// +// Callers MUST call VerifyBundle on the message before passing it to +// NextAttempt. NextAttempt does not re-run the signature checks; it +// assumes the bundle is verified and only applies the policy. +// +// The policy (RFC-21 Layer B): +// +// 1. Permanent exclusion (transport-blamable): a sender whose total +// overflow count across the bundle is at least +// OverflowExclusionThreshold is added to ExcludedSet forever. +// +// 2. Permanent exclusion (validation-blamable): senders with +// confirmed non-transport reject events. Phase 3.4 does not yet +// track reject events, so this is a no-op; the hook is in place +// for a later phase. +// +// 3. Silence parking (strictly transient): a sender in the +// previous attempt's IncludedSet that does not appear in the +// bundle, and is not permanently excluded, is added to the next +// attempt's TransientlyParked set. The attempt after that +// automatically reinstates them, so a falsely-silenced honest +// peer recovers without intervention. +// +// 4. Reinstatement: members in the previous attempt's +// TransientlyParked set automatically rejoin the next attempt's +// IncludedSet (unless they are now permanently excluded for +// another reason). +// +// 5. Infeasibility: if the next attempt's IncludedSet would have +// fewer than threshold members, return ErrAttemptInfeasible. +// +// threshold is the FROST signing threshold t for the key group; it +// is constant across attempts within a session. A threshold of zero +// disables the infeasibility check (useful in tests that exercise +// the policy independently from threshold semantics). +// +// The caller is responsible for supplying the DKG group public key +// from the same source the previous attempt used (the FFI signer +// material, per RFC-21 Decision 2); a different source would +// silently desynchronise the seed derivation. +func (c *inMemoryCoordinator) NextAttempt( + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, +) (attempt.AttemptContext, error) { + if bundle == nil { + return attempt.AttemptContext{}, errors.New( + "coordinator: cannot compute next attempt from nil bundle", + ) + } + c.mu.Lock() + record, ok := c.attempts[handle.id] + if !ok { + c.mu.Unlock() + return attempt.AttemptContext{}, ErrUnknownAttempt + } + prev := record.context + c.mu.Unlock() + + return computeNextAttempt(prev, bundle, threshold, dkgGroupPublicKey) +} + +// computeNextAttempt is the pure-function policy core: it takes the +// previous AttemptContext, a verified bundle, and the signing +// threshold, and returns the next AttemptContext. Factored out from +// NextAttempt so the policy is independently unit-testable without a +// Coordinator instance. +func computeNextAttempt( + prev attempt.AttemptContext, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, +) (attempt.AttemptContext, error) { + // (1) Permanent exclusion from overflow evidence (transport + // blamable). + overflowBlamed := overflowBlamedSenders(bundle, OverflowExclusionThreshold) + + // (2) Permanent exclusion from reject evidence (validation + // blamable). Counts across reasons are summed per-sender. + rejectBlamed := rejectBlamedSenders(bundle, RejectExclusionThreshold) + + // (3) Permanent exclusion from conflict evidence (equivocation + // blamable). First-write-wins disagreements by the same + // sender within an attempt are taken as proof of byzantine + // behaviour. + conflictBlamed := conflictBlamedSenders(bundle, ConflictExclusionThreshold) + + // Merge into permanent exclusion. + exclSet := newMemberSet() + exclSet.addAll(prev.ExcludedSet) + exclSet.addAll(overflowBlamed) + exclSet.addAll(rejectBlamed) + exclSet.addAll(conflictBlamed) + + // (3) Silence parking: senders in prev.IncludedSet but not in + // bundle, that we are not now permanently excluding. + bundleSenders := bundleSenderSet(bundle) + parkSet := newMemberSet() + for _, m := range prev.IncludedSet { + if bundleSenders.contains(m) { + continue + } + if exclSet.contains(m) { + continue + } + parkSet.add(m) + } + + // (4) Original signer set persists across transitions as + // IncludedSet ∪ ExcludedSet ∪ TransientlyParked. Reinstate + // previously parked members by re-including them + // (unless newly permanently excluded -- which they cannot be, + // since they could not have submitted overflow evidence + // this attempt). + original := newMemberSet() + original.addAll(prev.IncludedSet) + original.addAll(prev.ExcludedSet) + original.addAll(prev.TransientlyParked) + + included := original.sorted() + included = filterOut(included, exclSet) + included = filterOut(included, parkSet) + + // (5) Infeasibility check. + if threshold > 0 && uint(len(included)) < threshold { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %d eligible, threshold %d", + ErrAttemptInfeasible, + len(included), + threshold, + ) + } + + // Convert ExcludedSet to its canonical (sorted, deduped) slice. + nextExcluded := exclSet.sorted() + nextParked := parkSet.sorted() + + next, err := attempt.NewAttemptContextWithParking( + prev.SessionID, + prev.KeyGroupID, + dkgGroupPublicKey, + prev.MessageDigest, + prev.AttemptNumber+1, + included, + nextExcluded, + nextParked, + ) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "coordinator: next attempt construction: %w", + err, + ) + } + return next, nil +} + +// overflowBlamedSenders returns the senders whose total overflow +// count across every snapshot in the bundle is at least the +// supplied threshold. Counts are summed (not averaged) so a sender +// hitting the threshold from one observer alone is sufficient. +func overflowBlamedSenders( + bundle *TransitionMessage, + threshold uint, +) []group.MemberIndex { + counts := map[group.MemberIndex]uint{} + for i := range bundle.Bundle { + for _, entry := range bundle.Bundle[i].Overflows { + counts[entry.Sender] += entry.Count + } + } + return blamedSenders(counts, threshold) +} + +// rejectBlamedSenders returns the senders whose total reject count +// (summed across all observers AND across all rejection reasons) +// meets the supplied threshold. Reasons are not differentiated at +// the policy layer; the recorder bounds per-reason quotas +// separately so a peer cannot spam one reason to mask another. +func rejectBlamedSenders( + bundle *TransitionMessage, + threshold uint, +) []group.MemberIndex { + counts := map[group.MemberIndex]uint{} + for i := range bundle.Bundle { + for _, entry := range bundle.Bundle[i].Rejects { + counts[entry.Sender] += entry.Count + } + } + return blamedSenders(counts, threshold) +} + +// conflictBlamedSenders returns the senders whose total +// first-write-wins-conflict count across the bundle meets the +// supplied threshold. A single conflict suffices under the +// default ConflictExclusionThreshold (= 1) because an honest peer +// retransmitting always sends byte-identical bytes. +func conflictBlamedSenders( + bundle *TransitionMessage, + threshold uint, +) []group.MemberIndex { + counts := map[group.MemberIndex]uint{} + for i := range bundle.Bundle { + for _, entry := range bundle.Bundle[i].Conflicts { + counts[entry.Sender] += entry.Count + } + } + return blamedSenders(counts, threshold) +} + +// blamedSenders extracts the deterministically-sorted list of +// senders whose accumulated count meets the threshold. Factored +// out so the three category helpers share the same canonicalisation. +func blamedSenders( + counts map[group.MemberIndex]uint, + threshold uint, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0) + for sender, count := range counts { + if count >= threshold { + out = append(out, sender) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// bundleSenderSet returns the set of senders that submitted a +// snapshot to the bundle. +func bundleSenderSet(bundle *TransitionMessage) *memberSet { + out := newMemberSet() + for i := range bundle.Bundle { + out.add(bundle.Bundle[i].SenderID()) + } + return out +} + +// memberSet is a small helper for set arithmetic over +// group.MemberIndex. Sufficient for the small (≤256) sizes the +// coordinator deals with. +type memberSet struct { + m map[group.MemberIndex]struct{} +} + +func newMemberSet() *memberSet { + return &memberSet{m: map[group.MemberIndex]struct{}{}} +} + +func (s *memberSet) add(member group.MemberIndex) { s.m[member] = struct{}{} } +func (s *memberSet) contains(m group.MemberIndex) bool { + _, ok := s.m[m] + return ok +} + +func (s *memberSet) addAll(members []group.MemberIndex) { + for _, m := range members { + s.add(m) + } +} + +func (s *memberSet) sorted() []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(s.m)) + for m := range s.m { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// filterOut returns members not in the excluded set, preserving +// input order. +func filterOut( + members []group.MemberIndex, + excluded *memberSet, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(members)) + for _, m := range members { + if !excluded.contains(m) { + out = append(out, m) + } + } + return out +} diff --git a/pkg/frost/roast/next_attempt_categories_test.go b/pkg/frost/roast/next_attempt_categories_test.go new file mode 100644 index 0000000000..0729ae13e6 --- /dev/null +++ b/pkg/frost/roast/next_attempt_categories_test.go @@ -0,0 +1,165 @@ +package roast + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// buildBundleWithCategories constructs a TransitionMessage where each +// observer contributes the same per-(category, sender) evidence -- one +// reject reason and one conflict per "blamed" sender per observer. +// Useful for verifying the cross-observer summing behaviour. +func buildBundleWithCategories( + t *testing.T, + prev attempt.AttemptContext, + rejects map[group.MemberIndex][]string, + conflicts []group.MemberIndex, +) *TransitionMessage { + t.Helper() + prevHash := prev.Hash() + bundle := make([]LocalEvidenceSnapshot, 0, len(prev.IncludedSet)) + for _, sender := range prev.IncludedSet { + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + var rejectEntries []RejectEntry + for blamedSender, reasons := range rejects { + for _, r := range reasons { + rejectEntries = append(rejectEntries, RejectEntry{ + Sender: blamedSender, + Reason: r, + Count: 1, + }) + } + } + sortRejectEntriesForTest(rejectEntries) + if len(rejectEntries) > 0 { + snap.Rejects = rejectEntries + } + var conflictEntries []ConflictEntry + for _, blamedSender := range conflicts { + conflictEntries = append(conflictEntries, ConflictEntry{ + Sender: blamedSender, + Count: 1, + }) + } + if len(conflictEntries) > 0 { + snap.Conflicts = conflictEntries + } + bundle = append(bundle, snap) + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } +} + +func sortRejectEntriesForTest(entries []RejectEntry) { + for i := 1; i < len(entries); i++ { + for j := i; j > 0 && (entries[j].Sender < entries[j-1].Sender || + (entries[j].Sender == entries[j-1].Sender && entries[j].Reason < entries[j-1].Reason)); j-- { + entries[j], entries[j-1] = entries[j-1], entries[j] + } + } +} + +func TestNextAttempt_SingleRejectExcludesPermanently(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Every observer reports one reject against sender 3 → total + // count is len(IncludedSet) = 5 across observers, summed by + // rejectBlamedSenders. + bundle := buildBundleWithCategories( + t, + prev, + map[group.MemberIndex][]string{3: {"validation_gate_rejected"}}, + nil, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf("sender 3 must be excluded; got %v", next.ExcludedSet) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("sender 3 must not be in next IncludedSet") + } +} + +func TestNextAttempt_SingleConflictExcludesPermanently(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories( + t, + prev, + nil, + []group.MemberIndex{3}, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "sender 3 must be excluded after a single conflict; got %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_RejectAndConflictBothExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories( + t, + prev, + map[group.MemberIndex][]string{2: {"validation_gate_rejected"}}, + []group.MemberIndex{4}, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 2) { + t.Fatalf("sender 2 (reject) must be excluded; got %v", next.ExcludedSet) + } + if !memberSliceContains(next.ExcludedSet, 4) { + t.Fatalf("sender 4 (conflict) must be excluded; got %v", next.ExcludedSet) + } +} + +func TestNextAttempt_EmptyRejectsAndConflicts_DoNotExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories(t, prev, nil, nil) + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("no evidence -> no exclusions; got %v", next.ExcludedSet) + } +} + +func TestRejectAndConflictThresholds_MatchRFC(t *testing.T) { + if RejectExclusionThreshold != 1 { + t.Fatalf( + "RFC-21 Layer B specifies reject threshold = 1; constant is %d", + RejectExclusionThreshold, + ) + } + if ConflictExclusionThreshold != 1 { + t.Fatalf( + "single conflict is sufficient evidence; constant is %d", + ConflictExclusionThreshold, + ) + } +} diff --git a/pkg/frost/roast/next_attempt_test.go b/pkg/frost/roast/next_attempt_test.go new file mode 100644 index 0000000000..47972dedd0 --- /dev/null +++ b/pkg/frost/roast/next_attempt_test.go @@ -0,0 +1,392 @@ +package roast + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// nextAttemptFixture builds a previous AttemptContext and an +// associated TransitionMessage for the NextAttempt-policy tests. +// Members 1..5 included; no excluded; no parking. By default every +// member submits a snapshot with no overflow events. +type nextAttemptFixture struct { + included []group.MemberIndex + excluded []group.MemberIndex + parked []group.MemberIndex + overflows map[group.MemberIndex]map[group.MemberIndex]uint + bundleSenders []group.MemberIndex // override default = included + attemptNumber uint32 + dkgGroupPublicKey []byte + threshold uint + sessionID string + messageDigest [attempt.MessageDigestLength]byte +} + +func newNextAttemptFixture() *nextAttemptFixture { + return &nextAttemptFixture{ + included: []group.MemberIndex{1, 2, 3, 4, 5}, + excluded: nil, + parked: nil, + overflows: map[group.MemberIndex]map[group.MemberIndex]uint{}, + bundleSenders: nil, + attemptNumber: 0, + dkgGroupPublicKey: []byte{0x01, 0x02, 0x03}, + threshold: 3, + sessionID: "session-next-attempt", + messageDigest: [attempt.MessageDigestLength]byte{0x42}, + } +} + +func (f *nextAttemptFixture) prev(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContextWithParking( + f.sessionID, + "key-group-next-attempt", + f.dkgGroupPublicKey, + f.messageDigest, + f.attemptNumber, + f.included, + f.excluded, + f.parked, + ) + if err != nil { + t.Fatalf("fixture prev: %v", err) + } + return ctx +} + +func (f *nextAttemptFixture) bundle(t *testing.T) *TransitionMessage { + t.Helper() + prev := f.prev(t) + prevHash := prev.Hash() + senders := f.bundleSenders + if senders == nil { + senders = append([]group.MemberIndex{}, f.included...) + } + bundle := make([]LocalEvidenceSnapshot, 0, len(senders)) + for _, s := range senders { + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(s), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + if entries, ok := f.overflows[s]; ok { + ov := make([]OverflowEntry, 0, len(entries)) + for sender, count := range entries { + ov = append(ov, OverflowEntry{Sender: sender, Count: count}) + } + snap.Overflows = sortedOverflowEntries(ov) + } + bundle = append(bundle, snap) + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } +} + +func sortedOverflowEntries(in []OverflowEntry) []OverflowEntry { + out := append([]OverflowEntry{}, in...) + // insertion sort; small slices. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Sender < out[j-1].Sender; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +func TestNextAttempt_NoEvidenceProducesIdenticalIncludedSet(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := f.bundle(t) + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSlicesEqual(next.IncludedSet, prev.IncludedSet) { + t.Fatalf( + "included set changed unexpectedly: prev=%v next=%v", + prev.IncludedSet, next.IncludedSet, + ) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("excluded set should be empty, got %v", next.ExcludedSet) + } + if len(next.TransientlyParked) != 0 { + t.Fatalf("parking set should be empty, got %v", next.TransientlyParked) + } + if next.AttemptNumber != prev.AttemptNumber+1 { + t.Fatalf( + "attempt number not incremented: got %d, want %d", + next.AttemptNumber, prev.AttemptNumber+1, + ) + } +} + +func TestNextAttempt_OverflowThresholdTriggersPermanentExclusion(t *testing.T) { + f := newNextAttemptFixture() + // Members 2..5 all report 1 overflow event each against sender 3. + // 4 observers × 1 event = 4 total = OverflowExclusionThreshold. + for observer := group.MemberIndex(2); observer <= 5; observer++ { + f.overflows[observer] = map[group.MemberIndex]uint{3: 1} + } + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 should be excluded; got included %v", next.IncludedSet) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf("sender 3 should appear in excluded set; got %v", next.ExcludedSet) + } +} + +func TestNextAttempt_OverflowBelowThresholdDoesNotExclude(t *testing.T) { + f := newNextAttemptFixture() + // Only 1 observer reports 1 overflow event against sender 3. + // 1 < threshold (4). + f.overflows[2] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 should remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_SilentMemberIsParkedTransiently(t *testing.T) { + f := newNextAttemptFixture() + // Only members 1, 2, 4, 5 submit; member 3 is silent. + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("silent sender 3 must not appear in next IncludedSet") + } + if !memberSliceContains(next.TransientlyParked, 3) { + t.Fatalf("silent sender 3 must appear in next TransientlyParked; got %v", next.TransientlyParked) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("silent sender 3 must not be permanently excluded") + } +} + +func TestNextAttempt_PreviouslyParkedAreReinstated(t *testing.T) { + f := newNextAttemptFixture() + // Previous attempt: members 1, 2, 4, 5 included; member 3 parked. + f.included = []group.MemberIndex{1, 2, 4, 5} + f.parked = []group.MemberIndex{3} + // Bundle: only the included set submits (parked cannot). + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf( + "previously parked member 3 must be reinstated; got included %v", + next.IncludedSet, + ) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatal("member 3 must not be re-parked") + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("member 3 must not be excluded") + } +} + +func TestNextAttempt_ParkingIsStrictlyTransient_NoEscalation(t *testing.T) { + // Demonstrate the full cycle: park, skip one attempt, reinstate. + // Attempt N: member 3 is silent. + // Attempt N+1: member 3 is parked, did not submit. + // Attempt N+2: member 3 is reinstated. + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + prev := f.prev(t) + bundle := f.bundle(t) + attemptN1, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("N -> N+1: %v", err) + } + if !memberSliceContains(attemptN1.TransientlyParked, 3) { + t.Fatalf("N+1 must park member 3; got %v", attemptN1.TransientlyParked) + } + if memberSliceContains(attemptN1.IncludedSet, 3) { + t.Fatal("member 3 must not be in N+1 IncludedSet (parked this attempt)") + } + + // Now compute attempt N+2 from a bundle where parked member 3 + // could not submit (legitimately), and members 1, 2, 4, 5 did + // submit. + attemptN1Hash := attemptN1.Hash() + bundleN1 := &TransitionMessage{ + AttemptContextHash: append([]byte{}, attemptN1Hash[:]...), + CoordinatorIDValue: 1, + Bundle: []LocalEvidenceSnapshot{ + {SenderIDValue: 1, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 2, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 4, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 5, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + }, + } + attemptN2, err := computeNextAttempt(attemptN1, bundleN1, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("N+1 -> N+2: %v", err) + } + if !memberSliceContains(attemptN2.IncludedSet, 3) { + t.Fatalf( + "N+2 must reinstate member 3; got included %v", + attemptN2.IncludedSet, + ) + } + if memberSliceContains(attemptN2.TransientlyParked, 3) { + t.Fatal("N+2 must not re-park member 3") + } + if memberSliceContains(attemptN2.ExcludedSet, 3) { + t.Fatal("N+2 must not permanently exclude member 3") + } +} + +func TestNextAttempt_OriginalSignerSetPreservedAcrossTransitions(t *testing.T) { + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} // 3 silent + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + originalSize := len(f.included) + nextSize := len(next.IncludedSet) + len(next.ExcludedSet) + len(next.TransientlyParked) + if nextSize != originalSize { + t.Fatalf( + "original signer set size not preserved: %d vs %d", + nextSize, originalSize, + ) + } +} + +func TestNextAttempt_PolicyIsDeterministic(t *testing.T) { + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + f.overflows[2] = map[group.MemberIndex]uint{1: 2} + f.overflows[5] = map[group.MemberIndex]uint{1: 2} + a, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("first compute: %v", err) + } + b, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("second compute: %v", err) + } + if a.Hash() != b.Hash() { + t.Fatalf("same inputs produced different next-attempt hashes") + } +} + +func TestNextAttempt_InfeasibilityWhenBelowThreshold(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 5 // Require all 5 members. + // Silently lose 2 members -> only 3 remain in IncludedSet, below + // threshold of 5. + f.bundleSenders = []group.MemberIndex{1, 2, 3} + _, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf("expected ErrAttemptInfeasible, got %v", err) + } +} + +func TestNextAttempt_ThresholdZeroDisablesInfeasibilityCheck(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 0 + // All members silent; without the infeasibility check, the next + // attempt has zero included members. This is documented as a + // test seam, not a production state. + f.bundleSenders = []group.MemberIndex{} + // We need at least one entry in the bundle for TransitionMessage + // to be valid. Add a no-op snapshot from member 1 even though + // they're "silent" by the policy's view. The policy only looks + // at bundle senders that intersect prev.IncludedSet, which all + // of them do here. So instead let's leave member 1 in the + // bundle alone and silent the rest. + f.bundleSenders = []group.MemberIndex{1} + // IncludedSet would become {1}; for threshold=0 that's still + // permitted. + _, err := computeNextAttempt(f.prev(t), f.bundle(t), 0, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("expected success with threshold=0, got %v", err) + } +} + +func TestNextAttempt_OverflowFromMultipleObserversIsSummed(t *testing.T) { + f := newNextAttemptFixture() + // 2 observers each report 2 overflow events = total 4 = threshold. + f.overflows[1] = map[group.MemberIndex]uint{3: 2} + f.overflows[2] = map[group.MemberIndex]uint{3: 2} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "sender 3 should be excluded by summed overflow; got %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_NilBundleRejected(t *testing.T) { + c := newSignedCoordinatorForMember(0) + handle, _ := c.BeginAttempt(newTestContext(t)) + _, err := c.NextAttempt(handle, nil, 3, []byte{0x01}) + if err == nil { + t.Fatal("expected error for nil bundle") + } +} + +func TestNextAttempt_UnknownHandleRejected(t *testing.T) { + c := newSignedCoordinatorForMember(0) + bogus := AttemptHandle{id: 999} + _, err := c.NextAttempt(bogus, &TransitionMessage{}, 3, []byte{0x01}) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestOverflowExclusionThreshold_MatchesRFC(t *testing.T) { + if OverflowExclusionThreshold != 4 { + t.Fatalf( + "RFC-21 Layer B specifies overflow threshold = 4; constant is %d", + OverflowExclusionThreshold, + ) + } +} + +func memberSliceContains(slice []group.MemberIndex, target group.MemberIndex) bool { + for _, m := range slice { + if m == target { + return true + } + } + return false +} + +func memberSlicesEqual(a, b []group.MemberIndex) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/frost/roast/seed_bridge.go b/pkg/frost/roast/seed_bridge.go new file mode 100644 index 0000000000..cfac471c59 --- /dev/null +++ b/pkg/frost/roast/seed_bridge.go @@ -0,0 +1,33 @@ +package roast + +import ( + "encoding/binary" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// foldAttemptSeed reduces an RFC-21 [32]byte AttemptSeed to the legacy +// int64 seed accepted by SelectCoordinator. The reduction takes the +// first 8 bytes of the seed as a big-endian uint64 and re-interprets +// the bits as int64. +// +// This is a sterile, named adapter, *not* a cryptographic reduction. +// Its only contract is determinism: byte-identical input must produce +// byte-identical int64 output on every honest signer, so the +// SelectCoordinator shuffle remains in agreement across the network. +// +// The remaining 24 bytes of the seed are deliberately ignored. They +// are still part of the seed binding (so any change to those bytes is +// detected at the AttemptContext.Hash() layer, which protocol +// messages already verify in Phase 1B), but they do not influence the +// shuffle. SelectCoordinator's math.Rand source is non-cryptographic +// and 64 bits of entropy are sufficient for its purpose. +// +// Callers must not compose foldAttemptSeed with additional hashing. +// If a future RFC requires a different reduction it must be a new +// named bridge with its own tests and migration story. +func foldAttemptSeed(seed [attempt.AttemptSeedLength]byte) int64 { + // #nosec G115 -- intentional uint64-to-int64 reinterpretation; the + // downstream rand.Source accepts any int64, including negative. + return int64(binary.BigEndian.Uint64(seed[:8])) +} diff --git a/pkg/frost/roast/seed_bridge_test.go b/pkg/frost/roast/seed_bridge_test.go new file mode 100644 index 0000000000..dcc68a6c6e --- /dev/null +++ b/pkg/frost/roast/seed_bridge_test.go @@ -0,0 +1,107 @@ +package roast + +import ( + "encoding/binary" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +func TestFoldAttemptSeed_IsDeterministic(t *testing.T) { + seed := [attempt.AttemptSeedLength]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + } + a := foldAttemptSeed(seed) + b := foldAttemptSeed(seed) + if a != b { + t.Fatalf("foldAttemptSeed not deterministic: %d != %d", a, b) + } +} + +func TestFoldAttemptSeed_TakesFirst8BytesBigEndian(t *testing.T) { + seed := [attempt.AttemptSeedLength]byte{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + } + got := foldAttemptSeed(seed) + if got != 1 { + t.Fatalf("first-8 BE decode wrong: got %d want 1", got) + } +} + +func TestFoldAttemptSeed_IgnoresBytesAfterIndex7(t *testing.T) { + // Document the contract: bytes 8..31 do not influence the output. + // Any change to those bytes is still caught at the + // AttemptContext.Hash() layer; the bridge merely surfaces the + // first 8. + base := [attempt.AttemptSeedLength]byte{ + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + } + mutated := base + for i := 8; i < attempt.AttemptSeedLength; i++ { + mutated[i] ^= 0xff + } + if foldAttemptSeed(base) != foldAttemptSeed(mutated) { + t.Fatal( + "bridge must ignore bytes 8..31 by contract; honest signers " + + "will desynchronise if this assumption changes", + ) + } +} + +func TestFoldAttemptSeed_FirstByteSwept(t *testing.T) { + // Sweep the high byte of the leading uint64; every value must + // produce a distinct int64. + seen := map[int64]struct{}{} + for hi := 0; hi < 256; hi++ { + var seed [attempt.AttemptSeedLength]byte + seed[0] = byte(hi) + got := foldAttemptSeed(seed) + if _, dup := seen[got]; dup { + t.Fatalf("collision on high-byte sweep at %d", hi) + } + seen[got] = struct{}{} + } + if len(seen) != 256 { + t.Fatalf("expected 256 distinct outputs, got %d", len(seen)) + } +} + +func TestFoldAttemptSeed_GoldenFixture(t *testing.T) { + // Locks the wire-format reduction so any future change to the + // bridge implementation is caught at code review. Two coordinator + // instances that disagree on this constant will produce + // divergent SelectCoordinator outputs and fracture the network. + seed := [attempt.AttemptSeedLength]byte{ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, + } + want := int64(binary.BigEndian.Uint64(seed[:8])) + got := foldAttemptSeed(seed) + if got != want { + t.Fatalf( + "golden fixture drift: got %d want %d (seed=%x)", + got, want, seed[:8], + ) + } + // Also assert the literal integer so a typo in the reference + // computation above is caught: 0xdeadbeefcafebabe (16045690984503098046 + // as uint64) reinterpreted as int64. + const wantLiteral int64 = -2401053089206453570 + if got != wantLiteral { + t.Fatalf( + "golden fixture int64 drift: got %d want %d", + got, wantLiteral, + ) + } +} diff --git a/pkg/frost/roast/signature.go b/pkg/frost/roast/signature.go new file mode 100644 index 0000000000..7e841ee6be --- /dev/null +++ b/pkg/frost/roast/signature.go @@ -0,0 +1,257 @@ +package roast + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// Signer produces operator-key signatures over canonical-encoded +// payloads. The ROAST coordinator state machine uses one Signer per +// node to sign its own LocalEvidenceSnapshot before broadcast, and +// the elected coordinator uses the same Signer to sign the assembled +// TransitionMessage bundle. +// +// Phase 3.3 (this file) defines the interface. Phase 4 wires it to +// pkg/net's operator-key signing surface so signatures are +// automatically attributable to the node's libp2p identity. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines. +type Signer interface { + // Sign returns a signature over the canonical payload produced + // by CanonicalSnapshotBytes or CanonicalBundleBytes. The + // returned signature is treated as opaque bytes by the + // coordinator state machine; the SignatureVerifier is the only + // component that interprets the byte sequence. + Sign(payload []byte) ([]byte, error) +} + +// SignatureVerifier verifies a signature attributed to a specific +// member. The verifier owns the member-to-public-key mapping; the +// coordinator state machine does not see public keys directly. +// +// Phase 3.3 (this file) defines the interface. Phase 4 wires it to +// pkg/net's member-keys table. +// +// Implementations must be safe for concurrent calls from multiple +// goroutines. +type SignatureVerifier interface { + // Verify returns nil if signature is a valid signature over + // payload produced by the operator key of signer. Returns a + // descriptive error otherwise. + Verify(payload []byte, signature []byte, signer group.MemberIndex) error +} + +// ErrSignatureInvalid is the canonical sentinel a SignatureVerifier +// returns when a signature does not validate against the supplied +// payload and signer. Callers that want to distinguish +// signature-verification failure from other errors should use +// errors.Is(err, ErrSignatureInvalid). +var ErrSignatureInvalid = errors.New("roast: signature is invalid") + +// ErrSignatureMissing is returned by VerifyBundle when a snapshot +// or bundle lacks the signature the protocol requires. +var ErrSignatureMissing = errors.New("roast: signature missing") + +// ErrCensorshipDetected is returned by VerifyBundle when a receiver +// finds its own LocalEvidenceSnapshot absent from a bundle the +// receiver expected to be present in. The receiver's snapshot is +// missing either because the elected coordinator dropped it +// (malicious or otherwise) or because the bundle was constructed +// before the receiver's submission arrived. In either case, the +// receiver must not feed the bundle into NextAttempt. +var ErrCensorshipDetected = errors.New( + "roast: own evidence snapshot missing from transition bundle (censorship or race)", +) + +// NoOpSigner returns a Signer whose Sign returns an empty signature. +// Suitable as a default in tests that do not exercise the signature +// pipeline, and as the implicit default of NewInMemoryCoordinator +// (which is preserved for backward compatibility with Phase 3.1 +// callers). +// +// A NoOpSigner-produced bundle is rejected by any non-NoOp verifier: +// the verifier sees a missing signature and fails closed. So the +// pair {NoOpSigner, NoOpSignatureVerifier} is only suitable when the +// caller wants to test the structural-aggregation pipeline in +// isolation from the crypto pipeline. +func NoOpSigner() Signer { return noOpSigner{} } + +// NoOpSignatureVerifier returns a SignatureVerifier that accepts +// every signature, including empty ones. Use ONLY in tests that do +// not exercise the signature pipeline. +func NoOpSignatureVerifier() SignatureVerifier { return noOpSignatureVerifier{} } + +type noOpSigner struct{} + +func (noOpSigner) Sign(_ []byte) ([]byte, error) { return nil, nil } + +type noOpSignatureVerifier struct{} + +func (noOpSignatureVerifier) Verify(_, _ []byte, _ group.MemberIndex) error { + return nil +} + +// CanonicalSnapshotBytes returns the byte stream over which a signer +// signs a LocalEvidenceSnapshot. The encoding excludes the +// OperatorSignature field so a verifier can recompute the bytes from +// the snapshot it received over the wire. +// +// The encoding is canonical JSON: the Overflows slice must already +// be sorted ascending by Sender (NewLocalEvidenceSnapshot guarantees +// this; Unmarshal enforces it). Any two honest signers seeing the +// same snapshot fields produce byte-identical canonical bytes. +func CanonicalSnapshotBytes(s *LocalEvidenceSnapshot) ([]byte, error) { + if s == nil { + return nil, errors.New("roast: cannot canonicalise a nil snapshot") + } + clone := LocalEvidenceSnapshot{ + SenderIDValue: s.SenderIDValue, + AttemptContextHash: s.AttemptContextHash, + Overflows: s.Overflows, + // OperatorSignature intentionally omitted -- it is the + // signature *over* this canonical encoding, not part of it. + } + return json.Marshal(&clone) +} + +// CanonicalBundleBytes returns the byte stream over which the elected +// coordinator signs a TransitionMessage. The encoding excludes the +// CoordinatorSignature field but *includes* every snapshot's +// OperatorSignature -- the coordinator's signature attests that +// these specific signed snapshots were assembled in this specific +// order. +// +// The Bundle slice must already be sorted ascending by SenderID; the +// canonical encoding assumes that invariant holds. +func CanonicalBundleBytes(m *TransitionMessage) ([]byte, error) { + if m == nil { + return nil, errors.New("roast: cannot canonicalise a nil transition message") + } + clone := TransitionMessage{ + AttemptContextHash: m.AttemptContextHash, + CoordinatorIDValue: m.CoordinatorIDValue, + Bundle: m.Bundle, + // CoordinatorSignature intentionally omitted. + } + return json.Marshal(&clone) +} + +// verifySnapshotSignature checks the OperatorSignature on a single +// LocalEvidenceSnapshot against the verifier's record of the +// snapshot's sender's operator key. +func verifySnapshotSignature( + verifier SignatureVerifier, + snapshot *LocalEvidenceSnapshot, +) error { + if len(snapshot.OperatorSignature) == 0 { + return fmt.Errorf( + "%w: snapshot from sender %d has no operator signature", + ErrSignatureMissing, + snapshot.SenderID(), + ) + } + payload, err := CanonicalSnapshotBytes(snapshot) + if err != nil { + return fmt.Errorf("canonical snapshot bytes: %w", err) + } + if err := verifier.Verify( + payload, + snapshot.OperatorSignature, + snapshot.SenderID(), + ); err != nil { + return fmt.Errorf( + "%w: sender %d: %s", + ErrSignatureInvalid, + snapshot.SenderID(), + err.Error(), + ) + } + return nil +} + +// verifyBundleSignature checks the CoordinatorSignature on a +// TransitionMessage against the verifier's record of the bundle's +// declared coordinator's operator key. The coordinator member index +// passed in must match the elected coordinator for the attempt; the +// caller (Coordinator.VerifyBundle) resolves this from the +// AttemptHandle. +func verifyBundleSignature( + verifier SignatureVerifier, + msg *TransitionMessage, + expectedCoordinator group.MemberIndex, +) error { + if len(msg.CoordinatorSignature) == 0 { + return fmt.Errorf( + "%w: transition message has no coordinator signature", + ErrSignatureMissing, + ) + } + if msg.CoordinatorID() != expectedCoordinator { + return fmt.Errorf( + "transition message coordinator id %d does not match expected %d for the attempt", + msg.CoordinatorID(), + expectedCoordinator, + ) + } + payload, err := CanonicalBundleBytes(msg) + if err != nil { + return fmt.Errorf("canonical bundle bytes: %w", err) + } + if err := verifier.Verify( + payload, + msg.CoordinatorSignature, + msg.CoordinatorID(), + ); err != nil { + return fmt.Errorf( + "%w: coordinator %d: %s", + ErrSignatureInvalid, + msg.CoordinatorID(), + err.Error(), + ) + } + return nil +} + +// verifyOwnObservationsPresent is the receiver-side censorship- +// detection check: every receiver that has already submitted its +// own LocalEvidenceSnapshot to the elected coordinator must find +// that snapshot in the resulting bundle. A coordinator that drops a +// receiver's snapshot is detected here. +// +// When selfMember is zero, the check is skipped: that signals a +// caller that has not (yet) submitted its own snapshot and therefore +// has no censorship claim to verify. +func verifyOwnObservationsPresent( + msg *TransitionMessage, + selfMember group.MemberIndex, + selfSubmission *LocalEvidenceSnapshot, +) error { + if selfMember == 0 || selfSubmission == nil { + return nil + } + for i := range msg.Bundle { + if msg.Bundle[i].SenderID() != selfMember { + continue + } + // Found the receiver's snapshot. The submitted-vs-bundled + // signature must be byte-identical -- a coordinator that + // re-signed or mutated the submission has tampered with + // observed evidence. + if !bytes.Equal( + msg.Bundle[i].OperatorSignature, + selfSubmission.OperatorSignature, + ) { + return fmt.Errorf( + "%w: own evidence snapshot signature mutated in bundle", + ErrCensorshipDetected, + ) + } + return nil + } + return ErrCensorshipDetected +} diff --git a/pkg/frost/roast/signature_test.go b/pkg/frost/roast/signature_test.go new file mode 100644 index 0000000000..1c37c53380 --- /dev/null +++ b/pkg/frost/roast/signature_test.go @@ -0,0 +1,252 @@ +package roast + +import ( + "bytes" + "crypto/sha256" + "errors" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// fakeSigner produces deterministic signatures of the form +// SHA256(memberID || payload) so tests can exercise the sign / verify +// pipeline without real crypto. Two fakeSigners with the same member +// id produce identical signatures. +type fakeSigner struct { + id group.MemberIndex +} + +func (f *fakeSigner) Sign(payload []byte) ([]byte, error) { + h := sha256.New() + h.Write([]byte{byte(f.id)}) + h.Write(payload) + return h.Sum(nil), nil +} + +// fakeVerifier mirrors fakeSigner's deterministic signature scheme so +// every member's signatures verify against the same recomputation. +// A signature attributed to memberID is valid iff it equals +// SHA256(memberID || payload). +type fakeVerifier struct{} + +func (fakeVerifier) Verify(payload, signature []byte, signer group.MemberIndex) error { + h := sha256.New() + h.Write([]byte{byte(signer)}) + h.Write(payload) + expected := h.Sum(nil) + if !bytes.Equal(expected, signature) { + return errors.New("fakeVerifier: signature does not match recomputed value") + } + return nil +} + +func TestNoOpSigner_ReturnsEmptySignature(t *testing.T) { + sig, err := NoOpSigner().Sign([]byte("payload")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sig) != 0 { + t.Fatalf("expected empty signature, got %x", sig) + } +} + +func TestNoOpSignatureVerifier_AcceptsEverything(t *testing.T) { + v := NoOpSignatureVerifier() + if err := v.Verify([]byte("a"), []byte("b"), 1); err != nil { + t.Fatalf("NoOp must accept everything: %v", err) + } + if err := v.Verify(nil, nil, 1); err != nil { + t.Fatalf("NoOp must accept nil payload + nil sig: %v", err) + } +} + +func TestNoOpSigner_IsConcurrencySafe(t *testing.T) { + signer := NoOpSigner() + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 32; j++ { + if _, err := signer.Sign([]byte("payload")); err != nil { + t.Errorf("Sign error under concurrency: %v", err) + return + } + } + }() + } + wg.Wait() +} + +func TestCanonicalSnapshotBytes_ExcludesOperatorSignature(t *testing.T) { + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2, 3: 4}, + }) + withoutSig, err := CanonicalSnapshotBytes(snap) + if err != nil { + t.Fatalf("canonical bytes (no sig): %v", err) + } + snap.OperatorSignature = []byte{0xff, 0xee} + withSig, err := CanonicalSnapshotBytes(snap) + if err != nil { + t.Fatalf("canonical bytes (with sig): %v", err) + } + if !bytes.Equal(withoutSig, withSig) { + t.Fatalf( + "adding OperatorSignature changed canonical bytes; got %s vs %s", + string(withoutSig), string(withSig), + ) + } +} + +func TestCanonicalSnapshotBytes_RejectsNil(t *testing.T) { + if _, err := CanonicalSnapshotBytes(nil); err == nil { + t.Fatal("expected error for nil snapshot") + } +} + +func TestCanonicalBundleBytes_ExcludesCoordinatorSignatureButIncludesSnapshots(t *testing.T) { + msg := buildValidTransitionMessage() + // Make sure each snapshot's OperatorSignature is non-empty so we + // can verify they appear in the canonical bytes. + for i := range msg.Bundle { + msg.Bundle[i].OperatorSignature = []byte{byte(i + 1)} + } + msg.CoordinatorSignature = []byte{0xaa, 0xbb} + canonical, err := CanonicalBundleBytes(msg) + if err != nil { + t.Fatalf("canonical bundle: %v", err) + } + // CoordinatorSignature bytes should not appear in the canonical + // payload (omitempty + nil in clone). + if bytes.Contains(canonical, []byte{0xaa, 0xbb}) { + t.Fatalf( + "CoordinatorSignature 0xaabb leaked into canonical bytes: %s", + string(canonical), + ) + } + // Each snapshot's OperatorSignature should appear via base64 + // "AQ==", "Ag==", "Aw==" (1, 2, 3 → 0x01, 0x02, 0x03). + for _, want := range []string{`"AQ=="`, `"Ag=="`, `"Aw=="`} { + if !bytes.Contains(canonical, []byte(want)) { + t.Fatalf( + "expected per-snapshot OperatorSignature %q in canonical bundle: %s", + want, string(canonical), + ) + } + } +} + +func TestCanonicalBundleBytes_RejectsNil(t *testing.T) { + if _, err := CanonicalBundleBytes(nil); err == nil { + t.Fatal("expected error for nil message") + } +} + +func TestVerifySnapshotSignature_RoundTripsThroughFakeSignerVerifier(t *testing.T) { + signer := &fakeSigner{id: 7} + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + payload, err := CanonicalSnapshotBytes(snap) + if err != nil { + t.Fatalf("canonical: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + snap.OperatorSignature = sig + if err := verifySnapshotSignature(fakeVerifier{}, snap); err != nil { + t.Fatalf("expected valid signature, got %v", err) + } +} + +func TestVerifySnapshotSignature_RejectsMissingSignature(t *testing.T) { + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + err := verifySnapshotSignature(fakeVerifier{}, snap) + if !errors.Is(err, ErrSignatureMissing) { + t.Fatalf("expected ErrSignatureMissing, got %v", err) + } +} + +func TestVerifySnapshotSignature_RejectsTamperedPayload(t *testing.T) { + signer := &fakeSigner{id: 7} + snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + payload, _ := CanonicalSnapshotBytes(snap) + sig, _ := signer.Sign(payload) + snap.OperatorSignature = sig + // Tamper: change the overflow set; the recomputed canonical + // bytes will no longer match. + snap.Overflows = []OverflowEntry{{Sender: 99, Count: 1}} + if err := verifySnapshotSignature(fakeVerifier{}, snap); !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestVerifyBundleSignature_RoundTrip(t *testing.T) { + signer := &fakeSigner{id: 11} + msg := buildValidTransitionMessage() + msg.CoordinatorIDValue = 11 + msg.CoordinatorSignature = nil + payload, _ := CanonicalBundleBytes(msg) + sig, _ := signer.Sign(payload) + msg.CoordinatorSignature = sig + if err := verifyBundleSignature(fakeVerifier{}, msg, 11); err != nil { + t.Fatalf("expected verified, got %v", err) + } +} + +func TestVerifyBundleSignature_RejectsCoordinatorMismatch(t *testing.T) { + msg := buildValidTransitionMessage() + msg.CoordinatorIDValue = 1 + msg.CoordinatorSignature = []byte{0x01} + err := verifyBundleSignature(fakeVerifier{}, msg, 99) + if err == nil { + t.Fatal("expected coordinator mismatch error") + } +} + +func TestVerifyOwnObservationsPresent_RequiresIdenticalSignature(t *testing.T) { + selfSubmission := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + selfSubmission.OperatorSignature = []byte{0xab} + bundle := &TransitionMessage{ + Bundle: []LocalEvidenceSnapshot{ + func() LocalEvidenceSnapshot { + s := *selfSubmission + s.OperatorSignature = []byte{0xff} + return s + }(), + }, + } + if err := verifyOwnObservationsPresent(bundle, 7, selfSubmission); !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected on mutated sig, got %v", err) + } +} + +func TestVerifyOwnObservationsPresent_DetectsMissingSnapshot(t *testing.T) { + selfSubmission := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) + bundle := &TransitionMessage{ + Bundle: []LocalEvidenceSnapshot{ + *NewLocalEvidenceSnapshot(8, pinnedContextHash, attempt.Evidence{}), + }, + } + if err := verifyOwnObservationsPresent(bundle, 7, selfSubmission); !errors.Is(err, ErrCensorshipDetected) { + t.Fatalf("expected ErrCensorshipDetected, got %v", err) + } +} + +func TestVerifyOwnObservationsPresent_SkipsWhenSelfZero(t *testing.T) { + bundle := &TransitionMessage{Bundle: []LocalEvidenceSnapshot{}} + if err := verifyOwnObservationsPresent(bundle, 0, nil); err != nil { + t.Fatalf("expected skip, got %v", err) + } +} + +func TestVerifyOwnObservationsPresent_SkipsWhenNoSelfSubmission(t *testing.T) { + bundle := &TransitionMessage{Bundle: []LocalEvidenceSnapshot{}} + if err := verifyOwnObservationsPresent(bundle, 7, nil); err != nil { + t.Fatalf("expected skip when no self submission, got %v", err) + } +} diff --git a/pkg/frost/roast/signing_retry_adapter.go b/pkg/frost/roast/signing_retry_adapter.go new file mode 100644 index 0000000000..b65a4cfd06 --- /dev/null +++ b/pkg/frost/roast/signing_retry_adapter.go @@ -0,0 +1,142 @@ +package roast + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// MemberToParticipantResolver maps a ROAST group.MemberIndex to the +// participant-identifier type the legacy signing-retry path uses +// (typically chain.Address in keep-core production flows, but the +// interface is intentionally generic in T so pkg/frost/roast does +// not import any caller-side type). +// +// Implementations are wallet-scoped: each FROST signing flow +// constructs a resolver from its existing wallet/group state at the +// call site and passes it to EvaluateRoastRetryForSigning or +// SigningRetryAdapter. +type MemberToParticipantResolver[T any] interface { + // For returns the participant identifier corresponding to the + // given member index. Returns an error if the member is unknown + // to the resolver (out-of-range index, evicted member, etc.). + For(member group.MemberIndex) (T, error) +} + +// EvaluateRoastRetryForSigning bridges the ROAST coordinator state +// machine with the legacy signing-retry shape. Given the previous +// attempt's handle and a verified TransitionMessage, it computes +// the next attempt's IncludedSet, converts each member index to its +// resolver-supplied participant identifier, and returns both the +// participant list and the full AttemptContext. +// +// Callers MUST call Coordinator.VerifyBundle on bundle before +// passing it to this function; the bundle is the load-bearing +// authoritative input to NextAttempt and an unverified bundle would +// silently fracture multi-instance agreement. +// +// Returns ErrAttemptInfeasible directly when the next attempt's +// included set would drop below threshold; the caller must +// propagate that to the session manager rather than swallow it. +// See RFC-21 Phase-5 Resolved Decision on infeasibility. +// +// The function is generic in T so it can be used with chain.Address +// in production keep-core flows and with simple test types +// (strings, ints) in unit tests. +func EvaluateRoastRetryForSigning[T any]( + coord Coordinator, + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, + resolver MemberToParticipantResolver[T], +) ([]T, attempt.AttemptContext, error) { + if coord == nil { + return nil, attempt.AttemptContext{}, fmt.Errorf( + "roast retry adapter: coordinator is nil", + ) + } + if resolver == nil { + var zero T + _ = zero + return nil, attempt.AttemptContext{}, fmt.Errorf( + "roast retry adapter: resolver is nil", + ) + } + nextCtx, err := coord.NextAttempt(handle, bundle, threshold, dkgGroupPublicKey) + if err != nil { + return nil, attempt.AttemptContext{}, err + } + participants := make([]T, 0, len(nextCtx.IncludedSet)) + for _, m := range nextCtx.IncludedSet { + t, err := resolver.For(m) + if err != nil { + return nil, attempt.AttemptContext{}, fmt.Errorf( + "roast retry adapter: resolver failed for member %d: %w", + m, + err, + ) + } + participants = append(participants, t) + } + return participants, nextCtx, nil +} + +// SigningRetryAdapter binds the inputs to EvaluateRoastRetryForSigning +// onto a struct so call sites can hold the configuration once and +// call EvaluateRetryParticipantsForSigning (legacy-shaped) per +// retry. Phase 6 migrates call sites to either the function or the +// struct -- whichever fits the existing call shape. +type SigningRetryAdapter[T any] struct { + Coordinator Coordinator + Handle AttemptHandle + Bundle *TransitionMessage + Threshold uint + DkgGroupPublicKey []byte + Resolver MemberToParticipantResolver[T] +} + +// EvaluateRetryParticipantsForSigning matches the shape of the +// legacy helper in pkg/frost/retry so call sites can adopt the +// adapter without changing their function-call surface. The legacy +// signature's parameters (groupMembers, seed, retryCount, +// retryParticipantsCount) are ignored: the AttemptContext bound to +// the handle is the source of truth for next-attempt selection. +// +// Returns the next IncludedSet's participants and any error from +// NextAttempt (typically ErrAttemptInfeasible). +func (a SigningRetryAdapter[T]) EvaluateRetryParticipantsForSigning( + _ []T, + _ int64, + _ uint, + _ uint, +) ([]T, error) { + participants, _, err := EvaluateRoastRetryForSigning( + a.Coordinator, + a.Handle, + a.Bundle, + a.Threshold, + a.DkgGroupPublicKey, + a.Resolver, + ) + return participants, err +} + +// NextAttemptContext returns the AttemptContext the adapter would +// transition to. Useful when callers need both the participant +// list and the context (e.g. to re-bind session orchestration to +// the new attempt's handle). +func (a SigningRetryAdapter[T]) NextAttemptContext() ( + attempt.AttemptContext, error, +) { + _, ctx, err := EvaluateRoastRetryForSigning( + a.Coordinator, + a.Handle, + a.Bundle, + a.Threshold, + a.DkgGroupPublicKey, + a.Resolver, + ) + return ctx, err +} diff --git a/pkg/frost/roast/signing_retry_adapter_test.go b/pkg/frost/roast/signing_retry_adapter_test.go new file mode 100644 index 0000000000..6272cc32c0 --- /dev/null +++ b/pkg/frost/roast/signing_retry_adapter_test.go @@ -0,0 +1,251 @@ +package roast + +import ( + "errors" + "fmt" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// addressResolverString is a deterministic resolver that maps +// member index N to the string "addr-N". Used by the adapter +// tests to verify the conversion path without needing chain types. +type addressResolverString struct{} + +func (addressResolverString) For(m group.MemberIndex) (string, error) { + if m == 0 { + return "", fmt.Errorf("zero member index") + } + return fmt.Sprintf("addr-%d", m), nil +} + +// failingResolver always errors. Used to verify that resolver +// failures propagate cleanly through the adapter. +type failingResolver struct{ err error } + +func (f failingResolver) For(_ group.MemberIndex) (string, error) { + return "", f.err +} + +// retryAdapterFixture provides a previously-completed attempt with +// a verified bundle that NextAttempt can transition from. +type retryAdapterFixture struct { + coord Coordinator + handle AttemptHandle + bundle *TransitionMessage + threshold uint + dkgPub []byte +} + +func newRetryAdapterFixture(t *testing.T) *retryAdapterFixture { + t.Helper() + members := []group.MemberIndex{1, 2, 3, 4, 5} + + // Use a throwaway coordinator to discover the elected + // coordinator, then build a real coordinator bound to that + // member as the aggregator. + scratch := NewInMemoryCoordinator() + ctx := mustBuildContext(t, members, nil, nil) + h0, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(h0) + + aggregator := newSignedCoordinatorForMember(elected) + handle, err := aggregator.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + for _, m := range members { + snap := signSnapshotForTest(t, NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{})) + if err := aggregator.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := aggregator.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + return &retryAdapterFixture{ + coord: aggregator, + handle: handle, + bundle: bundle, + threshold: 3, + dkgPub: []byte{0xab, 0xcd, 0xef}, + } +} + +func mustBuildContext( + t *testing.T, + included, excluded, parked []group.MemberIndex, +) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContextWithParking( + "session-test", + "key-group-test", + []byte{0xab, 0xcd, 0xef}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + included, + excluded, + parked, + ) + if err != nil { + t.Fatalf("build ctx: %v", err) + } + return ctx +} + +func TestEvaluateRoastRetryForSigning_HappyPath(t *testing.T) { + f := newRetryAdapterFixture(t) + + addresses, nextCtx, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, f.threshold, f.dkgPub, + addressResolverString{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(addresses) != 5 { + t.Fatalf("expected 5 addresses, got %d", len(addresses)) + } + for i, a := range addresses { + want := fmt.Sprintf("addr-%d", nextCtx.IncludedSet[i]) + if a != want { + t.Fatalf( + "address[%d]: got %q want %q", + i, a, want, + ) + } + } + if nextCtx.AttemptNumber != 1 { + t.Fatalf("attempt number: got %d want 1", nextCtx.AttemptNumber) + } +} + +func TestEvaluateRoastRetryForSigning_PropagatesInfeasibility(t *testing.T) { + f := newRetryAdapterFixture(t) + + _, _, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, 99, f.dkgPub, + addressResolverString{}, + ) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf("expected ErrAttemptInfeasible, got %v", err) + } +} + +func TestEvaluateRoastRetryForSigning_PropagatesResolverError(t *testing.T) { + f := newRetryAdapterFixture(t) + + sentinel := errors.New("resolver lookup failed") + _, _, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, f.threshold, f.dkgPub, + failingResolver{err: sentinel}, + ) + if err == nil { + t.Fatal("expected resolver error") + } + if !errors.Is(err, sentinel) { + t.Fatalf("expected wrapped sentinel, got %v", err) + } +} + +func TestEvaluateRoastRetryForSigning_RejectsNilCoordinator(t *testing.T) { + _, _, err := EvaluateRoastRetryForSigning[string]( + nil, AttemptHandle{}, &TransitionMessage{}, 3, []byte{0x01}, + addressResolverString{}, + ) + if err == nil { + t.Fatal("expected nil-coordinator error") + } +} + +func TestEvaluateRoastRetryForSigning_RejectsNilResolver(t *testing.T) { + _, _, err := EvaluateRoastRetryForSigning[string]( + NewInMemoryCoordinator(), + AttemptHandle{}, &TransitionMessage{}, 3, []byte{0x01}, + nil, + ) + if err == nil { + t.Fatal("expected nil-resolver error") + } +} + +func TestSigningRetryAdapter_LegacyShapeMatchesPureFunction(t *testing.T) { + f := newRetryAdapterFixture(t) + resolver := addressResolverString{} + + adapter := SigningRetryAdapter[string]{ + Coordinator: f.coord, + Handle: f.handle, + Bundle: f.bundle, + Threshold: f.threshold, + DkgGroupPublicKey: f.dkgPub, + Resolver: resolver, + } + + // Legacy parameters are ignored. + viaAdapter, err := adapter.EvaluateRetryParticipantsForSigning( + nil, 0, 0, 0, + ) + if err != nil { + t.Fatalf("adapter: %v", err) + } + viaFunc, _, err := EvaluateRoastRetryForSigning[string]( + f.coord, f.handle, f.bundle, f.threshold, f.dkgPub, resolver, + ) + if err != nil { + t.Fatalf("function: %v", err) + } + if len(viaAdapter) != len(viaFunc) { + t.Fatalf( + "adapter and function disagree on participant count: %d vs %d", + len(viaAdapter), len(viaFunc), + ) + } + for i := range viaAdapter { + if viaAdapter[i] != viaFunc[i] { + t.Fatalf("adapter[%d] = %q, function[%d] = %q", i, viaAdapter[i], i, viaFunc[i]) + } + } +} + +func TestSigningRetryAdapter_NextAttemptContextRoundTrip(t *testing.T) { + f := newRetryAdapterFixture(t) + adapter := SigningRetryAdapter[string]{ + Coordinator: f.coord, + Handle: f.handle, + Bundle: f.bundle, + Threshold: f.threshold, + DkgGroupPublicKey: f.dkgPub, + Resolver: addressResolverString{}, + } + ctx1, err := adapter.NextAttemptContext() + if err != nil { + t.Fatalf("first: %v", err) + } + ctx2, err := adapter.NextAttemptContext() + if err != nil { + t.Fatalf("second: %v", err) + } + if ctx1.Hash() != ctx2.Hash() { + t.Fatal("NextAttemptContext must be deterministic across calls") + } +} + +func TestSigningRetryAdapter_PropagatesInfeasibility(t *testing.T) { + f := newRetryAdapterFixture(t) + adapter := SigningRetryAdapter[string]{ + Coordinator: f.coord, + Handle: f.handle, + Bundle: f.bundle, + Threshold: 99, + DkgGroupPublicKey: f.dkgPub, + Resolver: addressResolverString{}, + } + _, err := adapter.EvaluateRetryParticipantsForSigning(nil, 0, 0, 0) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf("expected ErrAttemptInfeasible, got %v", err) + } +} diff --git a/pkg/frost/roast/transition_message.go b/pkg/frost/roast/transition_message.go new file mode 100644 index 0000000000..f8747bd4b7 --- /dev/null +++ b/pkg/frost/roast/transition_message.go @@ -0,0 +1,405 @@ +package roast + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastMessageTypePrefix is the per-protocol prefix every ROAST-layer +// wire message uses for its net.TaggedUnmarshaler Type(). Distinct +// from frost_signing/native_frost/ and frost_signing/native_tbtc_signer/ +// so the network router can dispatch unambiguously. +const roastMessageTypePrefix = "frost_signing/roast/" + +// LocalEvidenceSnapshotType is the stable Type() string for a single +// signer's signed evidence snapshot. +const LocalEvidenceSnapshotType = roastMessageTypePrefix + "local_evidence_snapshot" + +// TransitionMessageType is the stable Type() string for the +// coordinator-aggregated bundle. +const TransitionMessageType = roastMessageTypePrefix + "transition_message" + +// MaxSnapshotsPerBundle caps the number of LocalEvidenceSnapshot +// entries a TransitionMessage may carry. Sized for the worst-case +// production signing group plus headroom; rejects pathological +// bundles at Unmarshal time so a misbehaving peer cannot exhaust +// memory on the receiver. +const MaxSnapshotsPerBundle = 256 + +// MaxOperatorSignatureBytes caps the per-snapshot OperatorSignature +// length. Sized to accept secp256k1 DER (~72 bytes), ed25519 (64 +// bytes), and reasonable post-quantum candidates without committing +// to a specific scheme at this layer. Rejects oversize payloads. +const MaxOperatorSignatureBytes = 256 + +// MaxCoordinatorSignatureBytes caps the bundle-level +// CoordinatorSignature. Same justification as +// MaxOperatorSignatureBytes. +const MaxCoordinatorSignatureBytes = 256 + +// OverflowEntry is the JSON-friendly key/value pair representing one +// per-sender overflow count from an attempt.Evidence map. The slice +// representation is canonical (sorted by Sender ascending) so any +// two honest signers serialising the same evidence produce +// byte-identical JSON. +type OverflowEntry struct { + Sender group.MemberIndex `json:"sender"` + Count uint `json:"count"` +} + +// RejectEntry carries one per-(sender, reason) reject count from an +// attempt.Evidence map. The bundle's Rejects field is sorted +// ascending first by Sender, then by Reason, so two honest signers +// produce byte-identical canonical encodings. +type RejectEntry struct { + Sender group.MemberIndex `json:"sender"` + Reason string `json:"reason"` + Count uint `json:"count"` +} + +// ConflictEntry carries one per-sender conflict count -- the number +// of first-write-wins disagreements detected during the attempt. +// Sorted ascending by Sender for canonical encoding. +type ConflictEntry struct { + Sender group.MemberIndex `json:"sender"` + Count uint `json:"count"` +} + +// LocalEvidenceSnapshot is the per-signer signed evidence produced +// during a single attempt. It is the input to the coordinator's +// aggregation and to the receiver-side bundle verification. +// +// Phase 3.2 (this file) defines the wire type only. Signature +// computation and verification land in Phase 3.3. +type LocalEvidenceSnapshot struct { + SenderIDValue uint32 `json:"senderID"` + // AttemptContextHash binds the snapshot to the attempt the + // evidence describes. Always exactly 32 bytes. + AttemptContextHash []byte `json:"attemptContextHash"` + // Overflows is the canonical sorted form of the + // attempt.Evidence.Overflows map; sorted ascending by Sender. + // Omitted when no overflow events were observed. + Overflows []OverflowEntry `json:"overflows,omitempty"` + // Rejects is the canonical sorted form of the + // attempt.Evidence.Rejects map; sorted ascending first by Sender, + // then by Reason. Omitted when no validation-reject events were + // observed. Each entry counts the number of rejects observed + // for one (sender, reason) pair, saturated at the recorder's + // reject quota. + Rejects []RejectEntry `json:"rejects,omitempty"` + // Conflicts is the canonical sorted form of the + // attempt.Evidence.Conflicts map; sorted ascending by Sender. + // Omitted when no first-write-wins-conflict events were + // observed. + Conflicts []ConflictEntry `json:"conflicts,omitempty"` + // OperatorSignature is the signer's operator-key signature over + // the canonical encoding of (senderID, attemptContextHash, + // overflows, rejects, conflicts). Phase 3.3 defines the + // canonical-encoding algorithm and the verification routine. + OperatorSignature []byte `json:"operatorSignature,omitempty"` +} + +// NewLocalEvidenceSnapshot converts an attempt.Evidence map into a +// LocalEvidenceSnapshot ready for signing and broadcast. The +// resulting snapshot's Overflows field is sorted ascending by +// Sender for deterministic JSON encoding. The OperatorSignature is +// left empty -- the caller must sign and populate it (Phase 3.3). +func NewLocalEvidenceSnapshot( + sender group.MemberIndex, + attemptContextHash [attempt.MessageDigestLength]byte, + evidence attempt.Evidence, +) *LocalEvidenceSnapshot { + overflows := make([]OverflowEntry, 0, len(evidence.Overflows)) + for s, c := range evidence.Overflows { + overflows = append(overflows, OverflowEntry{Sender: s, Count: c}) + } + sort.Slice(overflows, func(i, j int) bool { + return overflows[i].Sender < overflows[j].Sender + }) + + rejects := make([]RejectEntry, 0) + for s, entries := range evidence.Rejects { + for _, e := range entries { + rejects = append(rejects, RejectEntry{ + Sender: s, + Reason: e.Reason, + Count: e.Count, + }) + } + } + sort.Slice(rejects, func(i, j int) bool { + if rejects[i].Sender != rejects[j].Sender { + return rejects[i].Sender < rejects[j].Sender + } + return rejects[i].Reason < rejects[j].Reason + }) + + conflicts := make([]ConflictEntry, 0, len(evidence.Conflicts)) + for s, c := range evidence.Conflicts { + conflicts = append(conflicts, ConflictEntry{Sender: s, Count: c}) + } + sort.Slice(conflicts, func(i, j int) bool { + return conflicts[i].Sender < conflicts[j].Sender + }) + + snap := &LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, attemptContextHash[:]...), + Overflows: overflows, + } + if len(rejects) > 0 { + snap.Rejects = rejects + } + if len(conflicts) > 0 { + snap.Conflicts = conflicts + } + return snap +} + +// SenderID returns the snapshot's sender as a group.MemberIndex. +func (s *LocalEvidenceSnapshot) SenderID() group.MemberIndex { + return group.MemberIndex(s.SenderIDValue) +} + +// AttemptContextHashArray returns the 32-byte attempt context hash +// as a fixed-size array. Returns the zero array if the field is +// malformed (caller should have validated via Unmarshal first). +func (s *LocalEvidenceSnapshot) AttemptContextHashArray() [attempt.MessageDigestLength]byte { + var out [attempt.MessageDigestLength]byte + if len(s.AttemptContextHash) == attempt.MessageDigestLength { + copy(out[:], s.AttemptContextHash) + } + return out +} + +// Evidence reconstructs the attempt.Evidence map form from the +// canonical sorted-slice representation. The returned Evidence +// shares no state with the snapshot. +func (s *LocalEvidenceSnapshot) Evidence() attempt.Evidence { + out := attempt.Evidence{ + Overflows: make(map[group.MemberIndex]uint, len(s.Overflows)), + Rejects: make(map[group.MemberIndex][]attempt.RejectEntry, 0), + Conflicts: make(map[group.MemberIndex]uint, len(s.Conflicts)), + } + for _, e := range s.Overflows { + out.Overflows[e.Sender] = e.Count + } + for _, e := range s.Rejects { + out.Rejects[e.Sender] = append(out.Rejects[e.Sender], attempt.RejectEntry{ + Reason: e.Reason, + Count: e.Count, + }) + } + for _, e := range s.Conflicts { + out.Conflicts[e.Sender] = e.Count + } + return out +} + +// Type implements net.TaggedUnmarshaler. +func (s *LocalEvidenceSnapshot) Type() string { + return LocalEvidenceSnapshotType +} + +// Marshal serialises the snapshot to canonical JSON. The Overflows +// slice is sorted by Sender ascending in NewLocalEvidenceSnapshot +// so two honest signers with the same evidence produce +// byte-identical bytes. +func (s *LocalEvidenceSnapshot) Marshal() ([]byte, error) { + return json.Marshal(s) +} + +// Unmarshal parses canonical JSON into the snapshot and validates +// the resulting structure. +func (s *LocalEvidenceSnapshot) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, s); err != nil { + return err + } + return s.Validate() +} + +// Validate runs the structural checks Unmarshal applies after a JSON +// decode. Exposed publicly so callers that construct snapshots in +// memory (e.g. the Coordinator state machine) can validate without +// a marshal/unmarshal round-trip. +func (s *LocalEvidenceSnapshot) Validate() error { + if s.SenderIDValue == 0 { + return errors.New("local evidence snapshot: senderID is zero") + } + if len(s.AttemptContextHash) != attempt.MessageDigestLength { + return fmt.Errorf( + "local evidence snapshot: attemptContextHash length [%d], expected [%d]", + len(s.AttemptContextHash), + attempt.MessageDigestLength, + ) + } + if len(s.OperatorSignature) > MaxOperatorSignatureBytes { + return fmt.Errorf( + "local evidence snapshot: operatorSignature length [%d] exceeds cap [%d]", + len(s.OperatorSignature), + MaxOperatorSignatureBytes, + ) + } + for i := 1; i < len(s.Overflows); i++ { + if s.Overflows[i].Sender <= s.Overflows[i-1].Sender { + return fmt.Errorf( + "local evidence snapshot: overflows not sorted ascending or contain duplicate at index %d", + i, + ) + } + } + for i := 1; i < len(s.Rejects); i++ { + prev := s.Rejects[i-1] + cur := s.Rejects[i] + if cur.Sender < prev.Sender { + return fmt.Errorf( + "local evidence snapshot: rejects not sorted ascending by sender at index %d", + i, + ) + } + if cur.Sender == prev.Sender && cur.Reason <= prev.Reason { + return fmt.Errorf( + "local evidence snapshot: rejects not sorted ascending by reason or contain duplicate at index %d", + i, + ) + } + } + for i := 1; i < len(s.Conflicts); i++ { + if s.Conflicts[i].Sender <= s.Conflicts[i-1].Sender { + return fmt.Errorf( + "local evidence snapshot: conflicts not sorted ascending or contain duplicate at index %d", + i, + ) + } + } + return nil +} + +// TransitionMessage is the coordinator-aggregated bundle that drives +// the deterministic NextAttempt transition. It contains every +// participating signer's signed evidence snapshot for one attempt, +// plus the coordinator's own signature over the canonical bundle. +// +// Phase 3.2 (this file) defines the wire type. Aggregation, +// canonical encoding, and verification land in Phase 3.3. +type TransitionMessage struct { + // AttemptContextHash identifies the attempt the bundle + // describes. Must match every snapshot's AttemptContextHash. + // Always exactly 32 bytes. + AttemptContextHash []byte `json:"attemptContextHash"` + // CoordinatorIDValue is the member index of the elected + // coordinator that produced this bundle. + CoordinatorIDValue uint32 `json:"coordinatorID"` + // Bundle is the canonical sorted-by-SenderID list of signed + // evidence snapshots aggregated by the coordinator. + Bundle []LocalEvidenceSnapshot `json:"bundle"` + // CoordinatorSignature is the coordinator's operator-key + // signature over the canonical encoding of the bundle. Phase + // 3.3 defines the canonical-encoding algorithm and the + // verification routine. Phase 3.2 treats this field as opaque + // bytes with a length cap. + CoordinatorSignature []byte `json:"coordinatorSignature,omitempty"` +} + +// CoordinatorID returns the coordinator member index as a +// group.MemberIndex. +func (m *TransitionMessage) CoordinatorID() group.MemberIndex { + return group.MemberIndex(m.CoordinatorIDValue) +} + +// AttemptContextHashArray returns the 32-byte attempt context hash +// as a fixed-size array. Returns the zero array if the field is +// malformed (caller should have validated via Unmarshal first). +func (m *TransitionMessage) AttemptContextHashArray() [attempt.MessageDigestLength]byte { + var out [attempt.MessageDigestLength]byte + if len(m.AttemptContextHash) == attempt.MessageDigestLength { + copy(out[:], m.AttemptContextHash) + } + return out +} + +// Type implements net.TaggedUnmarshaler. +func (m *TransitionMessage) Type() string { + return TransitionMessageType +} + +// Marshal serialises the message to canonical JSON. +func (m *TransitionMessage) Marshal() ([]byte, error) { + return json.Marshal(m) +} + +// Unmarshal parses canonical JSON into the message and validates +// the structure: hash length, bundle size cap, signature size cap, +// snapshot validity, bundle ordering by SenderID ascending, and +// every snapshot binding to the same AttemptContextHash as the +// bundle. +func (m *TransitionMessage) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, m); err != nil { + return err + } + return m.Validate() +} + +// Validate runs the structural checks Unmarshal applies after a JSON +// decode: bundle hash length, bundle size cap, coordinator id, every +// snapshot's validity, bundle ordering, and intra-bundle hash +// consistency. Exposed publicly so callers that construct messages +// in memory can validate without a marshal/unmarshal round-trip. +func (m *TransitionMessage) Validate() error { + if len(m.AttemptContextHash) != attempt.MessageDigestLength { + return fmt.Errorf( + "transition message: attemptContextHash length [%d], expected [%d]", + len(m.AttemptContextHash), + attempt.MessageDigestLength, + ) + } + if m.CoordinatorIDValue == 0 { + return errors.New("transition message: coordinatorID is zero") + } + if len(m.Bundle) == 0 { + return errors.New("transition message: bundle must not be empty") + } + if len(m.Bundle) > MaxSnapshotsPerBundle { + return fmt.Errorf( + "transition message: bundle length [%d] exceeds cap [%d]", + len(m.Bundle), + MaxSnapshotsPerBundle, + ) + } + if len(m.CoordinatorSignature) > MaxCoordinatorSignatureBytes { + return fmt.Errorf( + "transition message: coordinatorSignature length [%d] exceeds cap [%d]", + len(m.CoordinatorSignature), + MaxCoordinatorSignatureBytes, + ) + } + for i := range m.Bundle { + if err := m.Bundle[i].Validate(); err != nil { + return fmt.Errorf( + "transition message: bundle[%d] invalid: %w", + i, err, + ) + } + if !bytes.Equal(m.Bundle[i].AttemptContextHash, m.AttemptContextHash) { + return fmt.Errorf( + "transition message: bundle[%d] attempt context hash does not match bundle hash", + i, + ) + } + if i > 0 { + if m.Bundle[i].SenderIDValue <= m.Bundle[i-1].SenderIDValue { + return fmt.Errorf( + "transition message: bundle not sorted ascending by senderID or contains duplicate at index %d", + i, + ) + } + } + } + return nil +} diff --git a/pkg/frost/roast/transition_message_test.go b/pkg/frost/roast/transition_message_test.go new file mode 100644 index 0000000000..4fadf13871 --- /dev/null +++ b/pkg/frost/roast/transition_message_test.go @@ -0,0 +1,381 @@ +package roast + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +var pinnedContextHash = [attempt.MessageDigestLength]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, +} + +func TestLocalEvidenceSnapshot_TypeIsStable(t *testing.T) { + s := &LocalEvidenceSnapshot{} + if got := s.Type(); got != LocalEvidenceSnapshotType { + t.Fatalf("Type() = %q, want %q", got, LocalEvidenceSnapshotType) + } + if !strings.HasPrefix(LocalEvidenceSnapshotType, roastMessageTypePrefix) { + t.Fatalf( + "Type() must be under the %q prefix; got %q", + roastMessageTypePrefix, LocalEvidenceSnapshotType, + ) + } +} + +func TestNewLocalEvidenceSnapshot_SortsOverflows(t *testing.T) { + evidence := attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{ + 5: 3, + 1: 2, + 3: 1, + }, + } + s := NewLocalEvidenceSnapshot(7, pinnedContextHash, evidence) + + if len(s.Overflows) != 3 { + t.Fatalf("expected 3 overflow entries, got %d", len(s.Overflows)) + } + for i := 1; i < len(s.Overflows); i++ { + if s.Overflows[i].Sender <= s.Overflows[i-1].Sender { + t.Fatalf( + "overflows not sorted ascending at index %d: %v", + i, s.Overflows, + ) + } + } + if s.SenderIDValue != 7 { + t.Fatalf("SenderIDValue = %d, want 7", s.SenderIDValue) + } + if !bytes.Equal(s.AttemptContextHash, pinnedContextHash[:]) { + t.Fatalf( + "AttemptContextHash mismatch: got %x want %x", + s.AttemptContextHash, pinnedContextHash[:], + ) + } +} + +func TestNewLocalEvidenceSnapshot_EmptyEvidenceOmitsOverflows(t *testing.T) { + s := NewLocalEvidenceSnapshot(1, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{}, + }) + if len(s.Overflows) != 0 { + t.Fatalf("expected empty overflows, got %v", s.Overflows) + } + data, err := s.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(data), "overflows") { + t.Fatalf( + "empty overflows should be omitted by omitempty; got JSON: %s", + string(data), + ) + } +} + +func TestLocalEvidenceSnapshot_RoundTrip(t *testing.T) { + original := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{ + 1: 2, + 3: 1, + 5: 3, + }, + }) + original.OperatorSignature = bytes.Repeat([]byte{0xab}, 64) + + data, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.SenderIDValue != original.SenderIDValue { + t.Fatalf("sender mismatch") + } + if !bytes.Equal(decoded.AttemptContextHash, original.AttemptContextHash) { + t.Fatalf("attempt context hash mismatch") + } + if len(decoded.Overflows) != len(original.Overflows) { + t.Fatalf( + "overflow length mismatch: %d vs %d", + len(decoded.Overflows), len(original.Overflows), + ) + } + if !bytes.Equal(decoded.OperatorSignature, original.OperatorSignature) { + t.Fatalf("signature mismatch") + } +} + +func TestLocalEvidenceSnapshot_RejectsZeroSender(t *testing.T) { + s := &LocalEvidenceSnapshot{ + SenderIDValue: 0, + AttemptContextHash: pinnedContextHash[:], + } + data, _ := json.Marshal(s) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "senderID is zero") { + t.Fatalf("expected zero-sender error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsWrongHashLength(t *testing.T) { + bad := []byte(`{ + "senderID": 1, + "attemptContextHash": "AAEC" + }`) + err := (&LocalEvidenceSnapshot{}).Unmarshal(bad) + if err == nil || !strings.Contains(err.Error(), "attemptContextHash length") { + t.Fatalf("expected hash-length error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsOversizeSignature(t *testing.T) { + s := NewLocalEvidenceSnapshot(1, pinnedContextHash, attempt.Evidence{}) + s.OperatorSignature = bytes.Repeat([]byte{0xff}, MaxOperatorSignatureBytes+1) + data, _ := json.Marshal(s) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "exceeds cap") { + t.Fatalf("expected signature-cap error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsUnsortedOverflows(t *testing.T) { + bad := &LocalEvidenceSnapshot{ + SenderIDValue: 1, + AttemptContextHash: pinnedContextHash[:], + Overflows: []OverflowEntry{ + {Sender: 5, Count: 1}, + {Sender: 1, Count: 1}, + }, + } + data, _ := json.Marshal(bad) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "not sorted") { + t.Fatalf("expected sort error, got %v", err) + } +} + +func TestLocalEvidenceSnapshot_RejectsDuplicateOverflowSender(t *testing.T) { + bad := &LocalEvidenceSnapshot{ + SenderIDValue: 1, + AttemptContextHash: pinnedContextHash[:], + Overflows: []OverflowEntry{ + {Sender: 3, Count: 1}, + {Sender: 3, Count: 1}, + }, + } + data, _ := json.Marshal(bad) + err := (&LocalEvidenceSnapshot{}).Unmarshal(data) + if err == nil { + t.Fatal("expected duplicate-sender error") + } +} + +func TestLocalEvidenceSnapshot_EvidenceReconstructsMap(t *testing.T) { + original := attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2, 3: 4}, + } + s := NewLocalEvidenceSnapshot(7, pinnedContextHash, original) + got := s.Evidence() + if len(got.Overflows) != len(original.Overflows) { + t.Fatalf( + "map size mismatch: got %d want %d", + len(got.Overflows), len(original.Overflows), + ) + } + for k, v := range original.Overflows { + if got.Overflows[k] != v { + t.Fatalf("overflow[%d]: got %d want %d", k, got.Overflows[k], v) + } + } +} + +func TestLocalEvidenceSnapshot_AttemptContextHashArrayHandlesMalformed(t *testing.T) { + s := &LocalEvidenceSnapshot{AttemptContextHash: []byte{0x01, 0x02}} + arr := s.AttemptContextHashArray() + var zero [attempt.MessageDigestLength]byte + if arr != zero { + t.Fatalf("expected zero array for malformed hash, got %x", arr) + } +} + +func TestTransitionMessage_TypeIsStable(t *testing.T) { + m := &TransitionMessage{} + if got := m.Type(); got != TransitionMessageType { + t.Fatalf("Type() = %q, want %q", got, TransitionMessageType) + } + if !strings.HasPrefix(TransitionMessageType, roastMessageTypePrefix) { + t.Fatalf("type prefix mismatch: %q", TransitionMessageType) + } +} + +func TestTransitionMessage_RoundTrip(t *testing.T) { + m := buildValidTransitionMessage() + data, err := m.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &TransitionMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.CoordinatorIDValue != m.CoordinatorIDValue { + t.Fatalf("coordinator id mismatch") + } + if len(decoded.Bundle) != len(m.Bundle) { + t.Fatalf( + "bundle size mismatch: %d vs %d", + len(decoded.Bundle), len(m.Bundle), + ) + } + for i := range decoded.Bundle { + if decoded.Bundle[i].SenderIDValue != m.Bundle[i].SenderIDValue { + t.Fatalf("bundle[%d] sender mismatch", i) + } + } +} + +func TestTransitionMessage_RejectsBadBundleOrdering(t *testing.T) { + m := buildValidTransitionMessage() + // Swap order to make it unsorted. + m.Bundle[0], m.Bundle[1] = m.Bundle[1], m.Bundle[0] + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "not sorted") { + t.Fatalf("expected sort error, got %v", err) + } +} + +func TestTransitionMessage_RejectsMismatchedBundleHash(t *testing.T) { + m := buildValidTransitionMessage() + // Mutate the first bundled snapshot's hash so it disagrees + // with the bundle-level hash. + m.Bundle[0].AttemptContextHash = make([]byte, attempt.MessageDigestLength) + for i := range m.Bundle[0].AttemptContextHash { + m.Bundle[0].AttemptContextHash[i] = 0xff + } + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "does not match bundle hash") { + t.Fatalf("expected hash-mismatch error, got %v", err) + } +} + +func TestTransitionMessage_RejectsEmptyBundle(t *testing.T) { + m := buildValidTransitionMessage() + m.Bundle = nil + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("expected empty-bundle error, got %v", err) + } +} + +func TestTransitionMessage_RejectsOversizeBundle(t *testing.T) { + m := buildValidTransitionMessage() + // Grow bundle beyond the cap by duplicating with monotonically + // increasing senders. + m.Bundle = make([]LocalEvidenceSnapshot, MaxSnapshotsPerBundle+1) + for i := range m.Bundle { + m.Bundle[i] = LocalEvidenceSnapshot{ + SenderIDValue: uint32(i + 1), + AttemptContextHash: append([]byte{}, m.AttemptContextHash...), + } + } + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "exceeds cap") { + t.Fatalf("expected oversize-bundle error, got %v", err) + } +} + +func TestTransitionMessage_RejectsZeroCoordinatorID(t *testing.T) { + m := buildValidTransitionMessage() + m.CoordinatorIDValue = 0 + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "coordinatorID is zero") { + t.Fatalf("expected zero-coordinator error, got %v", err) + } +} + +func TestTransitionMessage_RejectsOversizeCoordinatorSignature(t *testing.T) { + m := buildValidTransitionMessage() + m.CoordinatorSignature = bytes.Repeat([]byte{0xff}, MaxCoordinatorSignatureBytes+1) + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "exceeds cap") { + t.Fatalf("expected oversize-signature error, got %v", err) + } +} + +func TestTransitionMessage_RejectsBundleWithInvalidSnapshot(t *testing.T) { + m := buildValidTransitionMessage() + m.Bundle[0].SenderIDValue = 0 + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil || !strings.Contains(err.Error(), "senderID is zero") { + t.Fatalf("expected invalid-snapshot error, got %v", err) + } +} + +func TestTransitionMessage_RejectsDuplicateBundleSender(t *testing.T) { + m := buildValidTransitionMessage() + m.Bundle[1].SenderIDValue = m.Bundle[0].SenderIDValue + data, _ := json.Marshal(m) + err := (&TransitionMessage{}).Unmarshal(data) + if err == nil { + t.Fatal("expected duplicate-sender error") + } +} + +func TestTransitionMessage_DeterministicJSONForIdenticalInputs(t *testing.T) { + a := buildValidTransitionMessage() + b := buildValidTransitionMessage() + dataA, err := a.Marshal() + if err != nil { + t.Fatalf("marshal a: %v", err) + } + dataB, err := b.Marshal() + if err != nil { + t.Fatalf("marshal b: %v", err) + } + if !bytes.Equal(dataA, dataB) { + t.Fatalf( + "identical inputs produced different JSON:\n a=%s\n b=%s", + string(dataA), string(dataB), + ) + } +} + +func buildValidTransitionMessage() *TransitionMessage { + mkSnap := func(sender group.MemberIndex) LocalEvidenceSnapshot { + return LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + Overflows: []OverflowEntry{ + {Sender: 99, Count: 1}, + }, + } + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + CoordinatorIDValue: 1, + Bundle: []LocalEvidenceSnapshot{ + mkSnap(1), + mkSnap(2), + mkSnap(3), + }, + CoordinatorSignature: bytes.Repeat([]byte{0xee}, 64), + } +} diff --git a/pkg/frost/signing/attempt.go b/pkg/frost/signing/attempt.go new file mode 100644 index 0000000000..c0071db6e5 --- /dev/null +++ b/pkg/frost/signing/attempt.go @@ -0,0 +1,34 @@ +package signing + +import "github.com/keep-network/keep-core/pkg/protocol/group" + +// Attempt describes runtime context for a signing attempt coordinated by ROAST. +type Attempt struct { + // Number is the 1-based signing attempt counter for the same message. + Number uint + // CoordinatorMemberIndex is the member coordinating this attempt. + CoordinatorMemberIndex group.MemberIndex + // IncludedMembersIndexes are members participating in this attempt. + IncludedMembersIndexes []group.MemberIndex + // ExcludedMembersIndexes are members excluded from this attempt. + ExcludedMembersIndexes []group.MemberIndex +} + +func cloneAttempt(attempt *Attempt) *Attempt { + if attempt == nil { + return nil + } + + return &Attempt{ + Number: attempt.Number, + CoordinatorMemberIndex: attempt.CoordinatorMemberIndex, + IncludedMembersIndexes: append( + []group.MemberIndex{}, + attempt.IncludedMembersIndexes..., + ), + ExcludedMembersIndexes: append( + []group.MemberIndex{}, + attempt.ExcludedMembersIndexes..., + ), + } +} diff --git a/pkg/frost/signing/attempt_context_binding.go b/pkg/frost/signing/attempt_context_binding.go new file mode 100644 index 0000000000..5bb01b2cb9 --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding.go @@ -0,0 +1,72 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// AttemptContextHashFieldLength is the on-wire byte length of the +// optional AttemptContextHash field carried by the FROST/tbtc-signer +// protocol messages. The field is the canonical SHA-256 hash of the +// AttemptContext (see pkg/frost/roast/attempt), so 32 bytes. +const AttemptContextHashFieldLength = attempt.MessageDigestLength + +// validateAttemptContextHashField checks the length invariant for the +// optional AttemptContextHash field on protocol messages. An absent +// field (nil or zero-length slice) is valid; a present field must +// match AttemptContextHashFieldLength exactly. +// +// This is the only validation Phase 1B performs on the field. Higher- +// level acceptance (the receiver-side check that the hash matches the +// locally-computed AttemptContext) lands in a later RFC-21 phase +// behind a build tag, since enabling it requires honest peers to have +// rolled out the new field first. +func validateAttemptContextHashField(field []byte) error { + if len(field) == 0 { + return nil + } + if len(field) != AttemptContextHashFieldLength { + return fmt.Errorf( + "attempt context hash field has wrong length [%d], expected [%d] or absent", + len(field), + AttemptContextHashFieldLength, + ) + } + return nil +} + +// attemptContextHashFieldFromArray converts a fixed-size 32-byte hash +// into the slice form used on the wire. Returns a fresh slice so the +// caller's array cannot be mutated through the returned reference. +func attemptContextHashFieldFromArray( + hash [AttemptContextHashFieldLength]byte, +) []byte { + out := make([]byte, AttemptContextHashFieldLength) + copy(out, hash[:]) + return out +} + +// attemptContextHashFieldToArray converts a wire-form slice back to +// a fixed-size 32-byte hash plus a presence flag. Returns +// (zeroArray, false) when the field is absent. Caller has already +// validated length via validateAttemptContextHashField; this function +// trusts that invariant and panics on violation. +func attemptContextHashFieldToArray( + field []byte, +) ([AttemptContextHashFieldLength]byte, bool) { + var out [AttemptContextHashFieldLength]byte + if len(field) == 0 { + return out, false + } + if len(field) != AttemptContextHashFieldLength { + panic(fmt.Sprintf( + "attemptContextHashFieldToArray called with wrong-length field [%d]", + len(field), + )) + } + copy(out[:], field) + return out, true +} diff --git a/pkg/frost/signing/attempt_context_binding_test.go b/pkg/frost/signing/attempt_context_binding_test.go new file mode 100644 index 0000000000..659a32e275 --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_test.go @@ -0,0 +1,357 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +var pinnedAttemptContextHash = [AttemptContextHashFieldLength]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +} + +func TestValidateAttemptContextHashField_AcceptsAbsentOrCorrectLength(t *testing.T) { + tests := []struct { + name string + input []byte + }{ + {name: "nil is absent", input: nil}, + {name: "empty slice is absent", input: []byte{}}, + { + name: "exact length is accepted", + input: pinnedAttemptContextHash[:], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateAttemptContextHashField(tt.input); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateAttemptContextHashField_RejectsWrongLength(t *testing.T) { + tests := []struct { + name string + length int + }{ + {name: "too short", length: 31}, + {name: "too long", length: 33}, + {name: "one byte", length: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAttemptContextHashField( + bytes.Repeat([]byte{0xff}, tt.length), + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "wrong length") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestAttemptContextHashField_ArrayRoundTrip(t *testing.T) { + field := attemptContextHashFieldFromArray(pinnedAttemptContextHash) + if len(field) != AttemptContextHashFieldLength { + t.Fatalf( + "expected length %d, got %d", + AttemptContextHashFieldLength, len(field), + ) + } + got, present := attemptContextHashFieldToArray(field) + if !present { + t.Fatal("expected presence=true") + } + if got != pinnedAttemptContextHash { + t.Fatalf("array round-trip mismatch: got %x want %x", got, pinnedAttemptContextHash) + } +} + +func TestAttemptContextHashField_ArrayToArrayAbsent(t *testing.T) { + got, present := attemptContextHashFieldToArray(nil) + if present { + t.Fatal("expected presence=false for nil") + } + var zero [AttemptContextHashFieldLength]byte + if got != zero { + t.Fatalf("expected zero array, got %x", got) + } +} + +func TestAttemptContextHashField_FromArrayDoesNotAliasCaller(t *testing.T) { + arr := pinnedAttemptContextHash + field := attemptContextHashFieldFromArray(arr) + field[0] = 0xff + if arr[0] == 0xff { + t.Fatal("mutation through returned slice modified caller's array") + } +} + +func TestRoundOneCommitmentMessage_OptionalFieldRoundTrip(t *testing.T) { + original := &nativeFROSTRoundOneCommitmentMessage{ + SenderIDValue: 1, + SessionIDValue: "session-1", + ParticipantIdentifier: "p1", + CommitmentData: []byte{0xaa, 0xbb}, + } + + t.Run("absent field round-trips as absent", func(t *testing.T) { + data, err := original.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(data), "attemptContextHash") { + t.Fatalf( + "absent field should be omitted by omitempty, got JSON: %s", + string(data), + ) + } + decoded := &nativeFROSTRoundOneCommitmentMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected attempt context hash to be absent after round-trip") + } + }) + + t.Run("present field round-trips with same value", func(t *testing.T) { + withHash := *original + withHash.SetAttemptContextHash(pinnedAttemptContextHash) + data, err := withHash.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if !strings.Contains(string(data), "attemptContextHash") { + t.Fatalf( + "present field should appear in JSON, got: %s", + string(data), + ) + } + decoded := &nativeFROSTRoundOneCommitmentMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got, present := decoded.GetAttemptContextHash() + if !present { + t.Fatal("expected attempt context hash to be present") + } + if got != pinnedAttemptContextHash { + t.Fatalf("round-trip altered hash: got %x want %x", got, pinnedAttemptContextHash) + } + }) +} + +func TestRoundOneCommitmentMessage_BackwardCompatWithOldJSON(t *testing.T) { + // JSON emitted by a pre-Phase-1B peer: no attemptContextHash field + // at all. The new struct must accept it without error and report + // the hash as absent. + oldJSON := []byte(`{ + "senderID":1, + "sessionID":"session-1", + "participantIdentifier":"p1", + "commitmentData":"qrs=" + }`) + + decoded := &nativeFROSTRoundOneCommitmentMessage{} + if err := decoded.Unmarshal(oldJSON); err != nil { + t.Fatalf("unmarshal of old-format JSON failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected absent hash for old-format JSON") + } +} + +func TestRoundOneCommitmentMessage_RejectsWrongLengthHashField(t *testing.T) { + badJSON := []byte(`{ + "senderID":1, + "sessionID":"session-1", + "participantIdentifier":"p1", + "commitmentData":"qrs=", + "attemptContextHash":"AAEC" + }`) + + decoded := &nativeFROSTRoundOneCommitmentMessage{} + err := decoded.Unmarshal(badJSON) + if err == nil { + t.Fatal("expected wrong-length validation error") + } + if !strings.Contains(err.Error(), "wrong length") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRoundTwoSignatureShareMessage_OptionalFieldRoundTrip(t *testing.T) { + withHash := &nativeFROSTRoundTwoSignatureShareMessage{ + SenderIDValue: 2, + SessionIDValue: "session-2", + ParticipantIdentifier: "p2", + SignatureShareData: []byte{0xcc, 0xdd}, + } + withHash.SetAttemptContextHash(pinnedAttemptContextHash) + data, err := withHash.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + decoded := &nativeFROSTRoundTwoSignatureShareMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got, present := decoded.GetAttemptContextHash() + if !present || got != pinnedAttemptContextHash { + t.Fatalf("round-trip lost hash: present=%v got=%x", present, got) + } +} + +func TestRoundTwoSignatureShareMessage_BackwardCompatWithOldJSON(t *testing.T) { + oldJSON := []byte(`{ + "senderID":2, + "sessionID":"session-2", + "participantIdentifier":"p2", + "signatureShareData":"qrs=" + }`) + + decoded := &nativeFROSTRoundTwoSignatureShareMessage{} + if err := decoded.Unmarshal(oldJSON); err != nil { + t.Fatalf("unmarshal of old-format JSON failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected absent hash for old-format JSON") + } +} + +func TestRoundTwoSignatureShareMessage_RejectsWrongLengthHashField(t *testing.T) { + badJSON := []byte(`{ + "senderID":2, + "sessionID":"session-2", + "participantIdentifier":"p2", + "signatureShareData":"qrs=", + "attemptContextHash":"AAEC" + }`) + + decoded := &nativeFROSTRoundTwoSignatureShareMessage{} + err := decoded.Unmarshal(badJSON) + if err == nil { + t.Fatal("expected wrong-length validation error") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_OptionalFieldRoundTrip(t *testing.T) { + withHash := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 3, + SessionIDValue: "session-3", + ContributionIdentifier: 1, + ContributionData: []byte{0xee, 0xff}, + } + withHash.SetAttemptContextHash(pinnedAttemptContextHash) + data, err := withHash.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got, present := decoded.GetAttemptContextHash() + if !present || got != pinnedAttemptContextHash { + t.Fatalf("round-trip lost hash: present=%v got=%x", present, got) + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_BackwardCompatWithOldJSON(t *testing.T) { + oldJSON := []byte(`{ + "senderID":3, + "sessionID":"session-3", + "contributionIdentifier":1, + "contributionData":"qrs=" + }`) + + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + if err := decoded.Unmarshal(oldJSON); err != nil { + t.Fatalf("unmarshal of old-format JSON failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected absent hash for old-format JSON") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_RejectsWrongLengthHashField(t *testing.T) { + badJSON := []byte(`{ + "senderID":3, + "sessionID":"session-3", + "contributionIdentifier":1, + "contributionData":"qrs=", + "attemptContextHash":"AAEC" + }`) + + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + err := decoded.Unmarshal(badJSON) + if err == nil { + t.Fatal("expected wrong-length validation error") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessagesEqual_HashFieldDifferentiates(t *testing.T) { + base := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 1, + SessionIDValue: "session-1", + ContributionIdentifier: 1, + ContributionData: []byte{0xaa}, + } + withHashA := *base + withHashA.SetAttemptContextHash(pinnedAttemptContextHash) + + otherHash := pinnedAttemptContextHash + otherHash[0] ^= 0xff + withHashB := *base + withHashB.SetAttemptContextHash(otherHash) + + if buildTaggedTBTCSignerRoundContributionMessagesEqual(base, &withHashA) { + t.Fatal("base (no hash) vs with-hash must compare unequal") + } + if buildTaggedTBTCSignerRoundContributionMessagesEqual(&withHashA, &withHashB) { + t.Fatal("messages with different hashes must compare unequal") + } + withHashAClone := *base + withHashAClone.SetAttemptContextHash(pinnedAttemptContextHash) + if !buildTaggedTBTCSignerRoundContributionMessagesEqual(&withHashA, &withHashAClone) { + t.Fatal("messages with the same hash must compare equal") + } + if !buildTaggedTBTCSignerRoundContributionMessagesEqual(base, base) { + t.Fatal("identical-pointer comparison must be equal") + } +} + +func TestRoundOneCommitmentMessage_JSONEncoderOmitsAbsentField(t *testing.T) { + original := &nativeFROSTRoundOneCommitmentMessage{ + SenderIDValue: 1, + SessionIDValue: "s", + ParticipantIdentifier: "p", + CommitmentData: []byte{0xaa}, + } + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("re-decode failed: %v", err) + } + if _, ok := raw["attemptContextHash"]; ok { + t.Fatalf( + "omitempty did not suppress absent attemptContextHash; raw=%v", + raw, + ) + } +} diff --git a/pkg/frost/signing/attempt_context_binding_validation_default_build_test.go b/pkg/frost/signing/attempt_context_binding_validation_default_build_test.go new file mode 100644 index 0000000000..288758f241 --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_validation_default_build_test.go @@ -0,0 +1,34 @@ +//go:build frost_native && !frost_roast_retry + +package signing + +import ( + "testing" +) + +func TestVerifyMessageAttemptContextHash_DefaultBuildPassesEverything(t *testing.T) { + // Without the frost_roast_retry tag, currentAttemptHandleForCollect + // always returns ok=false, so the helper short-circuits to nil + // for every input. This guarantees that the receive-loop wiring + // never enforces the AttemptContextHash binding in the default + // build, matching the rollback promise made in the rollout + // guide (docs/development/frost-roast-retry-rollout.adoc). + msg := stubDefaultBuildMessage{} + if err := verifyMessageAttemptContextHash(msg, "any-session"); err != nil { + t.Fatalf( + "default build must always pass; got %v", + err, + ) + } +} + +// stubDefaultBuildMessage is the equivalent of the tagged-build +// test's stubMessage. Kept separate to avoid the tagged-build +// definition leaking into this build's compilation unit. +type stubDefaultBuildMessage struct{} + +func (stubDefaultBuildMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return [AttemptContextHashFieldLength]byte{}, false +} diff --git a/pkg/frost/signing/attempt_context_binding_validation_frost_native.go b/pkg/frost/signing/attempt_context_binding_validation_frost_native.go new file mode 100644 index 0000000000..24b19435ad --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_validation_frost_native.go @@ -0,0 +1,82 @@ +//go:build frost_native + +package signing + +import ( + "errors" + "fmt" +) + +// attemptContextHashCarrier is implemented by every protocol +// message type that carries the optional AttemptContextHash field +// introduced in RFC-21 Phase 1B. The validation helper below uses +// the interface so a single implementation covers all three +// FROST/tbtc-signer message types without duplicating per-type +// logic. +type attemptContextHashCarrier interface { + // GetAttemptContextHash returns the message's hash and a + // presence flag. Implementations are generated by the per-type + // Set/Get helpers in attempt_context_binding.go. + GetAttemptContextHash() ([AttemptContextHashFieldLength]byte, bool) +} + +// ErrAttemptContextHashMissing is returned when a message lacks +// the AttemptContextHash field while the session is bound to a +// ROAST attempt that requires it. Distinct sentinel so callers +// can map it to a specific RecordReject reason. +var ErrAttemptContextHashMissing = errors.New( + "attempt context hash required: session is ROAST-active but message omits the binding field", +) + +// ErrAttemptContextHashMismatch is returned when a message's +// AttemptContextHash does not match the session's currently-bound +// AttemptContext.Hash(). The peer is either talking about a stale +// attempt (post-transition) or trying to inject a message for a +// different context. +var ErrAttemptContextHashMismatch = errors.New( + "attempt context hash mismatch: message bound to a different attempt", +) + +// verifyMessageAttemptContextHash enforces the RFC-21 Phase-6 +// milestone that promotes the AttemptContextHash field from +// optional to required at the receive boundary, but only when the +// session has a ROAST-attempt binding registered. +// +// When no session-handle binding exists for sessionID (the typical +// state for non-ROAST sessions and for default builds), this +// function returns nil and lets the message through. The receive +// loop's other gates (shouldAcceptNativeFROSTMessage, etc.) still +// apply. +// +// When a binding exists -- i.e. the orchestration layer has begun +// an attempt for this session and is expecting the receive loops +// to participate -- the message must carry an AttemptContextHash +// that equals the bound context's Hash(). Returns +// ErrAttemptContextHashMissing or ErrAttemptContextHashMismatch on +// failure so the caller can RecordReject with a precise reason. +func verifyMessageAttemptContextHash( + msg attemptContextHashCarrier, + sessionID string, +) error { + _, ctx, ok := currentAttemptHandleForCollect(sessionID) + if !ok { + // No binding: legacy / non-ROAST mode. Skip enforcement + // so default builds and non-ROAST sessions stay + // observationally identical to pre-Phase-6 behaviour. + return nil + } + msgHash, present := msg.GetAttemptContextHash() + if !present { + return ErrAttemptContextHashMissing + } + expected := ctx.Hash() + if msgHash != expected { + return fmt.Errorf( + "%w: message=%x, current attempt=%x", + ErrAttemptContextHashMismatch, + msgHash[:4], + expected[:4], + ) + } + return nil +} diff --git a/pkg/frost/signing/attempt_context_binding_validation_frost_native_test.go b/pkg/frost/signing/attempt_context_binding_validation_frost_native_test.go new file mode 100644 index 0000000000..1a4338283b --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_validation_frost_native_test.go @@ -0,0 +1,159 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// stubMessage is a minimal attemptContextHashCarrier implementation +// for unit tests. The receive callbacks use the three real message +// types; the helper itself is exercised via this stub so the test +// surface stays small. +type stubMessage struct { + hash [AttemptContextHashFieldLength]byte + present bool +} + +func (s stubMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return s.hash, s.present +} + +func newOrchestrationTestContextForValidation(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "validation-test", + "key-group", + []byte{0x01, 0x02}, + [attempt.MessageDigestLength]byte{0x77}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +func TestVerifyMessageAttemptContextHash_NoBindingPasses(t *testing.T) { + // In the default build, no session-handle bindings exist so + // every call returns nil regardless of message contents. The + // receive loop's other gates still apply. + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + cases := []stubMessage{ + {present: false}, + {present: true, hash: [AttemptContextHashFieldLength]byte{0x01}}, + } + for _, msg := range cases { + if err := verifyMessageAttemptContextHash(msg, "session-x"); err != nil { + t.Fatalf( + "no-binding path must pass; got %v for msg %+v", + err, msg, + ) + } + } +} + +func TestVerifyMessageAttemptContextHash_BindingPresent_MatchingHashPasses(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-match", roast.AttemptHandle{}, ctx) + + expected := ctx.Hash() + msg := stubMessage{hash: expected, present: true} + if err := verifyMessageAttemptContextHash(msg, "session-match"); err != nil { + t.Fatalf("matching hash must pass; got %v", err) + } +} + +func TestVerifyMessageAttemptContextHash_BindingPresent_MissingHashFails(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-missing", roast.AttemptHandle{}, ctx) + + msg := stubMessage{present: false} + err := verifyMessageAttemptContextHash(msg, "session-missing") + if !errors.Is(err, ErrAttemptContextHashMissing) { + t.Fatalf( + "expected ErrAttemptContextHashMissing; got %v", + err, + ) + } +} + +func TestVerifyMessageAttemptContextHash_BindingPresent_MismatchedHashFails(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-mismatch", roast.AttemptHandle{}, ctx) + + wrong := [AttemptContextHashFieldLength]byte{} + for i := range wrong { + wrong[i] = 0xff + } + msg := stubMessage{hash: wrong, present: true} + err := verifyMessageAttemptContextHash(msg, "session-mismatch") + if !errors.Is(err, ErrAttemptContextHashMismatch) { + t.Fatalf( + "expected ErrAttemptContextHashMismatch; got %v", + err, + ) + } +} + +func TestVerifyMessageAttemptContextHash_RealMessageTypeIntegration(t *testing.T) { + // Exercise the helper against a real protocol message type + // (the round-one commitment from Phase 1B) rather than just + // the stub, so the test surface covers the actual Set/Get + // helpers code path. + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContextForValidation(t) + SetCurrentAttemptHandleForSession("session-real-msg", roast.AttemptHandle{}, ctx) + + expected := ctx.Hash() + msg := &nativeFROSTRoundOneCommitmentMessage{ + SenderIDValue: 1, + SessionIDValue: "session-real-msg", + ParticipantIdentifier: "p1", + CommitmentData: []byte{0x01}, + } + msg.SetAttemptContextHash(expected) + + if err := verifyMessageAttemptContextHash(msg, "session-real-msg"); err != nil { + t.Fatalf("real-message integration must pass; got %v", err) + } + + // Now mutate the context to break the binding. + differentCtx, _ := attempt.NewAttemptContext( + "session-real-msg", + "key-group", + []byte{0x99}, + [attempt.MessageDigestLength]byte{0x77}, + 1, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + SetCurrentAttemptHandleForSession("session-real-msg", roast.AttemptHandle{}, differentCtx) + + err := verifyMessageAttemptContextHash(msg, "session-real-msg") + if !errors.Is(err, ErrAttemptContextHashMismatch) { + t.Fatalf("rebinding must cause mismatch; got %v", err) + } +} diff --git a/pkg/frost/signing/attempt_context_from_request.go b/pkg/frost/signing/attempt_context_from_request.go new file mode 100644 index 0000000000..5e33d79ab4 --- /dev/null +++ b/pkg/frost/signing/attempt_context_from_request.go @@ -0,0 +1,223 @@ +//go:build frost_native + +package signing + +import ( + "errors" + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// ErrAttemptContextConstruction is the sentinel error class returned +// by BuildAttemptContextFromRequest for any failure during +// construction. Callers can match with errors.Is to distinguish +// it from runtime ROAST errors. +var ErrAttemptContextConstruction = errors.New( + "attempt context: construction failed", +) + +// BuildAttemptContextFromRequest converts a +// NativeExecutionFFISigningRequest into an attempt.AttemptContext +// suitable for Coordinator.BeginAttempt. The conversion: +// +// - SessionID, AttemptNumber, IncludedSet, ExcludedSet come from +// the request and its Attempt sub-struct directly. +// - TransientlyParked is empty: the existing Attempt struct does +// not carry parking info. Phase-7+ orchestration that drives +// multi-attempt sessions will need to thread parking metadata +// through; Phase 6 only handles attempt-zero shape. +// - MessageDigest is the request.Message bytes left-padded with +// zeros to 32 bytes, then truncated if longer. In BIP-340 +// production, request.Message is already a 32-byte digest of +// the tagged payload, so padding is a no-op. +// - DkgGroupPublicKey is extracted via +// ExtractDkgGroupPublicKeyFromMaterial. +// - KeyGroupID is derived format-aware: +// FrostUniFFIV2: HASH160(0x02 || xOnlyOutputKey) -- matches +// RFC-20's compatibility-alias scheme for legacy +// 20-byte wallet key hashes. +// FrostTBTCSignerV1: the raw KeyGroup string identifier from +// the tbtc-signer material, which is already a canonical +// per-group handle. +// - AttemptSeed = SHA256(DkgGroupPublicKey || SessionID || +// MessageDigest) per RFC-21 Decision 2. +// +// Critically, the FFI signer material is decoded *first* so any +// extraction failure is surfaced before the AttemptContext is +// constructed. This enforces the ordering Gemini flagged in the +// Phase-6 design review: AttemptContext must never be built from +// undecoded material because the seed derivation would silently +// fail. +// +// Returns ErrAttemptContextConstruction-wrapped errors for any +// failure during the construction. Returns ErrUnsupportedSignerMaterialFormat +// (via errors.Is) when the material's format is not extractable +// (e.g. FrostUniFFIV1 today). +func BuildAttemptContextFromRequest( + request *NativeExecutionFFISigningRequest, +) (attempt.AttemptContext, error) { + if request == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: request is nil", + ErrAttemptContextConstruction, + ) + } + if request.Message == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: request message is nil", + ErrAttemptContextConstruction, + ) + } + if request.SignerMaterial == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: signer material is nil", + ErrAttemptContextConstruction, + ) + } + if request.Attempt == nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: attempt metadata is nil", + ErrAttemptContextConstruction, + ) + } + + // Strict ordering: extract DKG group public key (which decodes + // the signer material) BEFORE deriving the context. A failure + // here propagates directly without leaving a half-built + // context. + dkgPub, err := ExtractDkgGroupPublicKeyFromMaterial(request.SignerMaterial) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + + keyGroupID, err := deriveKeyGroupID(request.SignerMaterial, dkgPub) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + + digest, err := messageDigestFromBigInt(request.Message) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + + // AttemptNumber on the keep-core Attempt struct is 1-based + // (1 = first attempt). RFC-21's AttemptContext.AttemptNumber is + // 0-based. Convert by subtracting 1 (Attempt.Number must be + // >= 1). + if request.Attempt.Number == 0 { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: request.Attempt.Number is zero (must be >= 1)", + ErrAttemptContextConstruction, + ) + } + attemptNumber := uint32(request.Attempt.Number - 1) + + ctx, err := attempt.NewAttemptContextWithParking( + request.SessionID, + keyGroupID, + dkgPub, + digest, + attemptNumber, + request.Attempt.IncludedMembersIndexes, + request.Attempt.ExcludedMembersIndexes, + nil, // Phase 6 ships attempt-zero shape; parking lands in Phase 7+ orchestration. + ) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %w", + ErrAttemptContextConstruction, + err, + ) + } + return ctx, nil +} + +// deriveKeyGroupID computes the AttemptContext KeyGroupID field +// from the signer material plus the already-extracted DKG group +// public key. The derivation is format-aware: +// +// - FrostUniFFIV2: HASH160(0x02 || dkgPub) -- the compressed +// 33-byte form prefixed with 0x02 matches the legacy +// compatibility-alias scheme RFC-20 introduced for 20-byte +// wallet pub-key-hashes. dkgPub here is the 32-byte x-only +// output key. +// - FrostTBTCSignerV1: the raw KeyGroup string from the tbtc- +// signer material. That string is the canonical handle. +// +// Returns an error for unknown formats; the caller will already +// have rejected unsupported formats via ExtractDkgGroupPublicKeyFromMaterial, +// so reaching the default arm here is an internal consistency +// error. +func deriveKeyGroupID( + signerMaterial *NativeSignerMaterial, + dkgPub []byte, +) (string, error) { + switch signerMaterial.Format { + case NativeSignerMaterialFormatFrostUniFFIV2: + if len(dkgPub) != frost.OutputKeySize { + return "", fmt.Errorf( + "derive key group id: FrostUniFFIV2 x-only key length %d, expected %d", + len(dkgPub), + frost.OutputKeySize, + ) + } + var outputKey frost.OutputKey + copy(outputKey[:], dkgPub) + alias := frost.WalletPublicKeyHashCompatibilityAlias(outputKey) + return fmt.Sprintf("%x", alias[:]), nil + case NativeSignerMaterialFormatFrostTBTCSignerV1: + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return "", fmt.Errorf("derive key group id: %w", err) + } + return payload.KeyGroup, nil + default: + return "", fmt.Errorf( + "derive key group id: cannot derive id from format %q", + signerMaterial.Format, + ) + } +} + +// messageDigestFromBigInt converts a *big.Int message to the +// 32-byte digest shape AttemptContext expects. Big-int values +// shorter than 32 bytes are left-padded with zeros (big.Int.Bytes +// strips leading zeros). Values longer than 32 bytes return an +// error -- a real digest never exceeds 32 bytes for SHA-256. +func messageDigestFromBigInt( + message *big.Int, +) ([attempt.MessageDigestLength]byte, error) { + var out [attempt.MessageDigestLength]byte + if message == nil { + return out, fmt.Errorf("message is nil") + } + bz := message.Bytes() + if len(bz) > attempt.MessageDigestLength { + return out, fmt.Errorf( + "message digest length %d exceeds expected %d", + len(bz), + attempt.MessageDigestLength, + ) + } + // Left-pad with zeros: big.Int.Bytes strips leading zeros, so a + // 32-byte digest with a leading zero byte returns a 31-byte + // slice. Copy into the tail of `out` to restore canonical + // alignment. + copy(out[attempt.MessageDigestLength-len(bz):], bz) + return out, nil +} diff --git a/pkg/frost/signing/attempt_context_from_request_test.go b/pkg/frost/signing/attempt_context_from_request_test.go new file mode 100644 index 0000000000..e78adecf14 --- /dev/null +++ b/pkg/frost/signing/attempt_context_from_request_test.go @@ -0,0 +1,289 @@ +//go:build frost_native + +package signing + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newTestRequestWithUniFFIV2Material(t *testing.T, attemptNumber uint) *NativeExecutionFFISigningRequest { + t.Helper() + const hexKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + payload, _ := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "id-1", + Data: []byte{0x01}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: hexKey, + }, + }) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "session-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + }, + Attempt: &Attempt{ + Number: attemptNumber, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5}, + ExcludedMembersIndexes: nil, + }, + } +} + +func newTestRequestWithTBTCSignerV1Material(t *testing.T, attemptNumber uint) *NativeExecutionFFISigningRequest { + t.Helper() + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "tbtc-group-A", + }) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "session-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, + Attempt: &Attempt{ + Number: attemptNumber, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3}, + ExcludedMembersIndexes: nil, + }, + } +} + +func TestBuildAttemptContextFromRequest_UniFFIV2_HappyPath(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + ctx, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctx.SessionID != req.SessionID { + t.Fatalf("session id: got %q want %q", ctx.SessionID, req.SessionID) + } + if ctx.AttemptNumber != 0 { + t.Fatalf("attempt number: got %d, want 0 (Attempt.Number=1 maps to 0-based 0)", ctx.AttemptNumber) + } + if len(ctx.IncludedSet) != 5 { + t.Fatalf("included set: got %d, want 5", len(ctx.IncludedSet)) + } + if len(ctx.TransientlyParked) != 0 { + t.Fatalf("parked: got %d, want 0 (Phase 6 ships attempt-zero shape)", len(ctx.TransientlyParked)) + } +} + +func TestBuildAttemptContextFromRequest_UniFFIV2_KeyGroupIDDerivation(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + ctx, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Reproduce the expected derivation: HASH160(0x02 || dkgPub). + dkgPub, _ := ExtractDkgGroupPublicKeyFromMaterial(req.SignerMaterial) + var outputKey frost.OutputKey + copy(outputKey[:], dkgPub) + want := fmt.Sprintf("%x", frost.WalletPublicKeyHashCompatibilityAlias(outputKey)) + if ctx.KeyGroupID != want { + t.Fatalf( + "key group id: got %s, want %s", + ctx.KeyGroupID, want, + ) + } + if len(ctx.KeyGroupID) != 40 { + t.Fatalf("key group id hex length: got %d, want 40 (20 bytes)", len(ctx.KeyGroupID)) + } +} + +func TestBuildAttemptContextFromRequest_TBTCSignerV1_KeyGroupIDIsRawIdentifier(t *testing.T) { + req := newTestRequestWithTBTCSignerV1Material(t, 1) + ctx, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctx.KeyGroupID != "tbtc-group-A" { + t.Fatalf( + "key group id: got %q, want %q", + ctx.KeyGroupID, + "tbtc-group-A", + ) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilRequest(t *testing.T) { + _, err := BuildAttemptContextFromRequest(nil) + if !errors.Is(err, ErrAttemptContextConstruction) { + t.Fatalf("expected ErrAttemptContextConstruction, got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilMessage(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + req.Message = nil + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for nil message") + } + if !strings.Contains(err.Error(), "message is nil") { + t.Fatalf("error must mention nil message; got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilSignerMaterial(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + req.SignerMaterial = nil + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for nil signer material") + } + if !strings.Contains(err.Error(), "signer material is nil") { + t.Fatalf("error must mention nil signer material; got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_RejectsNilAttempt(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + req.Attempt = nil + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for nil attempt metadata") + } +} + +func TestBuildAttemptContextFromRequest_RejectsZeroAttemptNumber(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 0) + _, err := BuildAttemptContextFromRequest(req) + if err == nil { + t.Fatal("expected error for zero attempt number") + } + if !strings.Contains(err.Error(), "Attempt.Number is zero") { + t.Fatalf("error must mention zero attempt; got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_PropagatesExtractionErrors(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + req.SignerMaterial = &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte("{}"), + } + _, err := BuildAttemptContextFromRequest(req) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !errors.Is(err, ErrAttemptContextConstruction) { + t.Fatalf("expected ErrAttemptContextConstruction wrapper, got %v", err) + } +} + +func TestBuildAttemptContextFromRequest_AttemptNumberIsZeroBased(t *testing.T) { + cases := []struct { + legacyNumber uint + expectedZeroBased uint32 + }{ + {1, 0}, + {2, 1}, + {5, 4}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("legacy=%d", tc.legacyNumber), func(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, tc.legacyNumber) + ctx, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ctx.AttemptNumber != tc.expectedZeroBased { + t.Fatalf( + "got attempt number %d, want %d (legacy 1-based input %d)", + ctx.AttemptNumber, tc.expectedZeroBased, tc.legacyNumber, + ) + } + }) + } +} + +func TestMessageDigestFromBigInt_PadsShortBigInts(t *testing.T) { + bi := new(big.Int).SetBytes([]byte{0x01, 0x02}) + digest, err := messageDigestFromBigInt(bi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := [attempt.MessageDigestLength]byte{} + want[30] = 0x01 + want[31] = 0x02 + if digest != want { + t.Fatalf("padding wrong: got %x, want %x", digest, want) + } +} + +func TestMessageDigestFromBigInt_RejectsLongBigInts(t *testing.T) { + bi := new(big.Int).SetBytes(make([]byte, 33)) + bi.SetBit(bi, 264, 1) // 33-byte length + _, err := messageDigestFromBigInt(bi) + if err == nil { + t.Fatal("expected error for over-long message") + } +} + +func TestBuildAttemptContextFromRequest_DeterministicAcrossInvocations(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + a, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("first: %v", err) + } + b, err := BuildAttemptContextFromRequest(req) + if err != nil { + t.Fatalf("second: %v", err) + } + if a.Hash() != b.Hash() { + t.Fatalf( + "two calls with same request produced different hashes: %x vs %x", + a.Hash(), b.Hash(), + ) + } +} + +func TestBuildAttemptContextFromRequest_HashChangesWhenMessageDigestChanges(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + a, _ := BuildAttemptContextFromRequest(req) + req.Message = new(big.Int).SetBytes([]byte{0x99, 0x88, 0x77}) + b, _ := BuildAttemptContextFromRequest(req) + if a.Hash() == b.Hash() { + t.Fatal("hash must change when message digest changes") + } +} + +func TestBuildAttemptContextFromRequest_HashChangesWhenIncludedSetChanges(t *testing.T) { + req := newTestRequestWithUniFFIV2Material(t, 1) + a, _ := BuildAttemptContextFromRequest(req) + req.Attempt.IncludedMembersIndexes = []group.MemberIndex{1, 2, 3} + b, _ := BuildAttemptContextFromRequest(req) + if a.Hash() == b.Hash() { + t.Fatal("hash must change when included set changes") + } +} + +// Sanity check that the message digest padding produces the same +// bytes as a direct SHA-256 (just a smoke test on the constants). +func TestMessageDigestFromBigInt_SmokeTestSha256Length(t *testing.T) { + if attempt.MessageDigestLength != sha256.Size { + t.Fatalf( + "AttemptContext digest length %d != SHA-256 size %d", + attempt.MessageDigestLength, sha256.Size, + ) + } +} diff --git a/pkg/frost/signing/attempt_test.go b/pkg/frost/signing/attempt_test.go new file mode 100644 index 0000000000..8d8f87fbc2 --- /dev/null +++ b/pkg/frost/signing/attempt_test.go @@ -0,0 +1,41 @@ +package signing + +import ( + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestCloneAttempt(t *testing.T) { + original := &Attempt{ + Number: 3, + CoordinatorMemberIndex: 7, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 7}, + ExcludedMembersIndexes: []group.MemberIndex{4, 5, 6, 8}, + } + + cloned := cloneAttempt(original) + if !reflect.DeepEqual(original, cloned) { + t.Fatalf("unexpected clone\nexpected: [%+v]\nactual: [%+v]", original, cloned) + } + + if &original.IncludedMembersIndexes[0] == &cloned.IncludedMembersIndexes[0] { + t.Fatal("included members slice should be copied") + } + + if &original.ExcludedMembersIndexes[0] == &cloned.ExcludedMembersIndexes[0] { + t.Fatal("excluded members slice should be copied") + } + + cloned.IncludedMembersIndexes[0] = 99 + if original.IncludedMembersIndexes[0] == cloned.IncludedMembersIndexes[0] { + t.Fatal("mutating clone should not mutate original") + } +} + +func TestCloneAttempt_Nil(t *testing.T) { + if cloneAttempt(nil) != nil { + t.Fatal("expected nil clone") + } +} diff --git a/pkg/frost/signing/backend.go b/pkg/frost/signing/backend.go new file mode 100644 index 0000000000..4bf01e76a3 --- /dev/null +++ b/pkg/frost/signing/backend.go @@ -0,0 +1,241 @@ +package signing + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// ExecutionBackend represents a pluggable backend used by the FROST signing +// runtime. This enables seamless replacement of the transitional legacy engine +// with a native FROST/FFI-backed implementation. +type ExecutionBackend interface { + Name() string + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +type nativeExecutionAvailabilityReporter interface { + NativeExecutionAvailable() bool +} + +var ( + // ErrNativeExecutionBackendUnavailable is returned when native backend is + // requested but not linked in the current build. + ErrNativeExecutionBackendUnavailable = fmt.Errorf( + "native FROST signing backend is unavailable in this build", + ) + + // executionBackend, nativeExecutionAdapter, registeredNativeExecBridge, and + // nativeExecutionFFIExecutor are process-global runtime state. Tests + // mutating this state must run sequentially; do not use t.Parallel in such + // tests. + executionBackendMutex sync.RWMutex + executionBackend ExecutionBackend = newLegacyExecutionBackend() + nativeExecutionAdapter NativeExecutionAdapter + registeredNativeExecBridge NativeExecutionBridge + nativeExecutionFFIExecutor NativeExecutionFFIExecutor + nativeExecutionFFISigningPrimitiveProviderForBuild NativeExecutionFFISigningPrimitiveProviderForBuild + nativeExecutionMode = nativeExecutionModeFallbackAllowed +) + +// LegacyExecutionBackendName is a stable identifier of the transitional +// legacy tECDSA bridge backend. +const LegacyExecutionBackendName = legacyExecutionBackendName + +// NativeExecutionBackendName is a stable identifier of the native FROST +// execution backend. +const NativeExecutionBackendName = nativeExecutionBackendName + +type nativeExecutionModeValue uint8 + +const ( + // nativeExecutionModeFallbackAllowed means the native adapter may fall back + // to transitional legacy execution when native cryptography is unavailable. + nativeExecutionModeFallbackAllowed nativeExecutionModeValue = iota + // nativeExecutionModeStrict requires native cryptographic execution and + // does not allow fallback to transitional legacy execution. + nativeExecutionModeStrict +) + +func currentExecutionBackend() ExecutionBackend { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return executionBackend +} + +// SetExecutionBackend sets a runtime execution backend. +func SetExecutionBackend(backend ExecutionBackend) error { + if backend == nil { + return fmt.Errorf("execution backend is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + executionBackend = backend + return nil +} + +// ResetExecutionBackend restores the default transitional legacy backend. +func ResetExecutionBackend() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + executionBackend = newLegacyExecutionBackend() + nativeExecutionMode = nativeExecutionModeFallbackAllowed +} + +// CurrentExecutionBackendName returns the active backend name. +func CurrentExecutionBackendName() string { + return currentExecutionBackend().Name() +} + +// SetExecutionBackendByName configures the runtime backend by a stable name. +// +// Supported values: +// - "", "legacy", "legacy-tecdsa-bridge": transitional legacy bridge backend +// - "native": native route with transitional fallback to legacy when native +// cryptography is unavailable +// - "ffi": strict native route; no fallback to legacy execution +func SetExecutionBackendByName(name string) error { + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "legacy", legacyExecutionBackendName: + ResetExecutionBackend() + return nil + case "native": + previousMode := currentNativeExecutionMode() + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + + nativeBackend, err := currentNativeExecutionBackend() + if err != nil { + setNativeExecutionMode(previousMode) + return err + } + + if err := SetExecutionBackend(nativeBackend); err != nil { + setNativeExecutionMode(previousMode) + return err + } + + return nil + case "ffi": + previousMode := currentNativeExecutionMode() + setNativeExecutionMode(nativeExecutionModeStrict) + + nativeBackend, err := currentNativeExecutionBackend() + if err != nil { + setNativeExecutionMode(previousMode) + return err + } + + if err := SetExecutionBackend(nativeBackend); err != nil { + setNativeExecutionMode(previousMode) + return err + } + + return nil + default: + return fmt.Errorf("unknown FROST signing backend: [%s]", name) + } +} + +func currentNativeExecutionMode() nativeExecutionModeValue { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionMode +} + +func setNativeExecutionMode(mode nativeExecutionModeValue) { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionMode = mode +} + +func nativeExecutionFallbackAllowed() bool { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionMode == nativeExecutionModeFallbackAllowed +} + +// RegisterNativeExecutionAdapter sets a native adapter used by the +// native FROST execution backend. +func RegisterNativeExecutionAdapter(adapter NativeExecutionAdapter) error { + if adapter == nil { + return fmt.Errorf("native execution adapter is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionAdapter = adapter + + return nil +} + +// UnregisterNativeExecutionAdapter clears the native adapter registration. +func UnregisterNativeExecutionAdapter() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionAdapter = nil +} + +// RegisterNativeExecutionAdapterForBuild attempts to register the native +// adapter provided by the current build flavor. +// +// On default builds, this is a no-op. +// On `frost_native` builds, this registers the tagged native adapter. +func RegisterNativeExecutionAdapterForBuild() { + registerNativeExecutionAdapterForBuild() + RegisterNativeExecutionFFISigningPrimitiveForBuild() +} + +func currentNativeExecutionBackend() (ExecutionBackend, error) { + executionBackendMutex.RLock() + adapter := nativeExecutionAdapter + mode := nativeExecutionMode + executionBackendMutex.RUnlock() + + if adapter == nil { + return nil, fmt.Errorf( + "%w: no native execution adapter registered", + ErrNativeExecutionBackendUnavailable, + ) + } + + if mode == nativeExecutionModeStrict { + if reporter, ok := adapter.(nativeExecutionAvailabilityReporter); ok { + if !reporter.NativeExecutionAvailable() { + return nil, fmt.Errorf( + "%w: %w", + ErrNativeExecutionBackendUnavailable, + ErrNativeCryptographyUnavailable, + ) + } + } + } + + backend, err := newNativeExecutionBackend(adapter) + if err != nil { + return nil, fmt.Errorf( + "%w: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + + return backend, nil +} diff --git a/pkg/frost/signing/backend_test.go b/pkg/frost/signing/backend_test.go new file mode 100644 index 0000000000..3a20dac2c9 --- /dev/null +++ b/pkg/frost/signing/backend_test.go @@ -0,0 +1,530 @@ +package signing + +import ( + "context" + "errors" + "math/big" + "reflect" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type mockExecutionBackend struct { + name string + + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +type mockNativeExecutionAdapter struct { + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +type mockNativeExecutionAdapterWithAvailability struct { + *mockNativeExecutionAdapter + nativeExecutionAvailable bool +} + +func (meb *mockExecutionBackend) Name() string { + return meb.name +} + +func (meb *mockExecutionBackend) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + meb.executeCalls++ + meb.lastRequest = request + return meb.result, meb.err +} + +func (meb *mockExecutionBackend) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + meb.registerUnmarshallersCalls++ + meb.lastChannel = channel +} + +func (mnea *mockNativeExecutionAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + mnea.executeCalls++ + mnea.lastRequest = request + return mnea.result, mnea.err +} + +func (mnea *mockNativeExecutionAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mnea.registerUnmarshallersCalls++ + mnea.lastChannel = channel +} + +func (mneawa *mockNativeExecutionAdapterWithAvailability) NativeExecutionAvailable() bool { + return mneawa.nativeExecutionAvailable +} + +func TestCurrentExecutionBackendName_Default(t *testing.T) { + ResetExecutionBackend() + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected default backend name\nexpected: [%s]\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestSetExecutionBackend_Nil(t *testing.T) { + if err := SetExecutionBackend(nil); err == nil { + t.Fatal("expected nil backend error") + } +} + +func TestSetExecutionBackendByName(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + if err := SetExecutionBackendByName(""); err != nil { + t.Fatalf("unexpected default backend config error: [%v]", err) + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name for default config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + if err := SetExecutionBackendByName("LEGACY"); err != nil { + t.Fatalf("unexpected legacy backend config error: [%v]", err) + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name for legacy config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + err := SetExecutionBackendByName("native") + if err == nil { + t.Fatal("expected native backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected native backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal("expected fallback-allowed mode for native backend selection") + } + + err = SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected ffi backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal( + "expected previous fallback-allowed mode after failed ffi backend selection", + ) + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name after failed ffi config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + err = SetExecutionBackendByName("unknown") + if err == nil { + t.Fatal("expected unknown backend error") + } +} + +func TestSetExecutionBackendByName_NativeFailureRestoresPreviousMode( + t *testing.T, +) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + setNativeExecutionMode(nativeExecutionModeStrict) + if nativeExecutionFallbackAllowed() { + t.Fatal("expected strict mode before failed native backend selection") + } + + err := SetExecutionBackendByName("native") + if err == nil { + t.Fatal("expected native backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected native backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + + if nativeExecutionFallbackAllowed() { + t.Fatal("expected strict mode to be restored after failed native selection") + } + if CurrentExecutionBackendName() != legacyExecutionBackendName { + t.Fatalf( + "unexpected backend name after failed native config\\nexpected: [%s]\\nactual: [%s]", + legacyExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestSetExecutionBackendByName_FFIFailurePreservesNativeModeAndBackend( + t *testing.T, +) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + adapter := &mockNativeExecutionAdapterWithAvailability{ + mockNativeExecutionAdapter: &mockNativeExecutionAdapter{}, + nativeExecutionAvailable: false, + } + + if err := RegisterNativeExecutionAdapter(adapter); err != nil { + t.Fatalf("failed registering native execution adapter: [%v]", err) + } + + if err := SetExecutionBackendByName("native"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal("expected fallback-allowed mode after native backend selection") + } + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for native config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + err := SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected ffi backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected strict-mode availability error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !nativeExecutionFallbackAllowed() { + t.Fatal( + "expected fallback-allowed mode to be preserved after failed ffi selection", + ) + } + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name after failed ffi config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestSetExecutionBackendByName_NativeAdapterRegistered(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + expectedResult := &Result{Signature: &frost.Signature{}} + adapter := &mockNativeExecutionAdapter{ + result: expectedResult, + } + + if err := RegisterNativeExecutionAdapter(adapter); err != nil { + t.Fatalf("failed registering native execution adapter: [%v]", err) + } + + if err := SetExecutionBackendByName("ffi"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for native config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + if nativeExecutionFallbackAllowed() { + t.Fatal("expected strict mode for ffi backend selection") + } + + if err := SetExecutionBackendByName("native"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + if !nativeExecutionFallbackAllowed() { + t.Fatal("expected fallback-allowed mode for native backend selection") + } + + executeResult, err := Execute( + context.Background(), + nil, + big.NewInt(100), + "session-id", + 1, + nil, + 10, + 4, + nil, + nil, + nil, + ) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if executeResult != expectedResult { + t.Fatalf( + "unexpected execute result\\nexpected: [%+v]\\nactual: [%+v]", + expectedResult, + executeResult, + ) + } + + if adapter.executeCalls != 1 { + t.Fatalf("unexpected native execute calls count: [%d]", adapter.executeCalls) + } + + RegisterUnmarshallers(nil) + + if adapter.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected native register unmarshallers calls count: [%d]", + adapter.registerUnmarshallersCalls, + ) + } +} + +func TestSetExecutionBackendByName_FFIStrictAvailabilityCheck(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + adapter := &mockNativeExecutionAdapterWithAvailability{ + mockNativeExecutionAdapter: &mockNativeExecutionAdapter{}, + nativeExecutionAvailable: false, + } + + if err := RegisterNativeExecutionAdapter(adapter); err != nil { + t.Fatalf("failed registering native execution adapter: [%v]", err) + } + + err := SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected ffi backend unavailable error") + } + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected strict-mode availability error\\nexpected: [%v]\\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if err := SetExecutionBackendByName("native"); err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + if CurrentExecutionBackendName() != nativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for native config\\nexpected: [%s]\\nactual: [%s]", + nativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestRegisterNativeExecutionAdapter_Nil(t *testing.T) { + if err := RegisterNativeExecutionAdapter(nil); err == nil { + t.Fatal("expected nil native adapter error") + } +} + +func TestRegisterNativeExecutionBridge_Nil(t *testing.T) { + if err := RegisterNativeExecutionBridge(nil); err == nil { + t.Fatal("expected nil native bridge error") + } +} + +func TestRegisterNativeExecutionFFIExecutor_Nil(t *testing.T) { + if err := RegisterNativeExecutionFFIExecutor(nil); err == nil { + t.Fatal("expected nil native FFI executor error") + } +} + +func TestExecute_DelegatesToCurrentBackend(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + expectedResult := &Result{Signature: &frost.Signature{}} + backend := &mockExecutionBackend{ + name: "mock", + result: expectedResult, + } + + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("failed setting backend: [%v]", err) + } + + attempt := &Attempt{ + Number: 2, + CoordinatorMemberIndex: 5, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 5}, + ExcludedMembersIndexes: []group.MemberIndex{3, 4, 6}, + } + + result, err := Execute( + context.Background(), + nil, + big.NewInt(100), + "session-id", + 1, + nil, + 10, + 4, + nil, + nil, + attempt, + ) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if backend.executeCalls != 1 { + t.Fatalf("unexpected execute calls count: [%d]", backend.executeCalls) + } + + received := backend.lastRequest + if received == nil { + t.Fatal("expected backend request") + } + + if received.Attempt == attempt { + t.Fatal("expected request attempt clone, got same pointer") + } + + if !reflect.DeepEqual(received.Attempt, attempt) { + t.Fatalf( + "unexpected request attempt\nexpected: [%+v]\nactual: [%+v]", + attempt, + received.Attempt, + ) + } + + received.Attempt.IncludedMembersIndexes[0] = 99 + if attempt.IncludedMembersIndexes[0] == 99 { + t.Fatal("mutating backend request attempt should not mutate caller attempt") + } +} + +func TestRegisterUnmarshallers_DelegatesToCurrentBackend(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + backend := &mockExecutionBackend{name: "mock"} + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("failed setting backend: [%v]", err) + } + + RegisterUnmarshallers(nil) + + if backend.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected register unmarshallers calls count: [%d]", + backend.registerUnmarshallersCalls, + ) + } + + if backend.lastChannel != nil { + t.Fatal("expected nil channel to be forwarded unchanged") + } +} diff --git a/pkg/frost/signing/dkg_group_pubkey_extraction.go b/pkg/frost/signing/dkg_group_pubkey_extraction.go new file mode 100644 index 0000000000..07290e5552 --- /dev/null +++ b/pkg/frost/signing/dkg_group_pubkey_extraction.go @@ -0,0 +1,131 @@ +//go:build frost_native + +package signing + +import ( + "encoding/hex" + "errors" + "fmt" +) + +// ErrUnsupportedSignerMaterialFormat is returned by +// ExtractDkgGroupPublicKeyFromMaterial when the material's Format +// field names a signer-material variant the helper cannot extract +// a DKG group public key from. The current implementation accepts +// FrostUniFFIV2 and FrostTBTCSignerV1; FrostUniFFIV1 is rejected +// because the legacy bridge format does not expose the group key. +// +// Per RFC-21 Phase-6 Resolved Decision: the Phase 7 manifest flip +// is gated on verified migration off V1 across production signers, +// so this error class is expected to disappear by the time ROAST +// retry ships unconditionally. +var ErrUnsupportedSignerMaterialFormat = errors.New( + "dkg group public key: unsupported signer-material format for extraction", +) + +// ExtractDkgGroupPublicKeyFromMaterial returns the DKG-validated +// group public key from the supplied NativeSignerMaterial in the +// canonical byte representation that attempt.DeriveAttemptSeed +// consumes. Two honest signers feeding the same material into this +// helper produce byte-identical outputs. +// +// Format handling: +// +// - FrostUniFFIV2: decode payload as nativeFROSTUniFFIV2SignerMaterial; +// hex-decode PublicKeyPackage.VerifyingKey. This is the x-only +// output key produced by the native FROST DKG. +// +// - FrostTBTCSignerV1: decode payload as NativeTBTCSignerMaterialPayload; +// return the raw bytes of the KeyGroup identifier. The tbtc-signer +// engine treats KeyGroup as the canonical handle for the FROST +// key group; every honest signer running the same tbtc-signer +// build agrees on its bytes. +// +// - FrostUniFFIV1: returns ErrUnsupportedSignerMaterialFormat. +// V1 material is the legacy bridge format that does not carry +// the group public key in a form Phase 6 can extract. +// +// Callers MUST use the returned bytes only as the +// DkgGroupPublicKey input to attempt.DeriveAttemptSeed; the bytes +// are not interchangeable across format boundaries (a UniFFIV2 key +// and a TBTCSignerV1 key for the "same" logical group produce +// different bytes -- they are different formats). Production +// signing groups must run on a single uniform format. +func ExtractDkgGroupPublicKeyFromMaterial( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "dkg group public key: signer material is nil", + ) + } + switch signerMaterial.Format { + case NativeSignerMaterialFormatFrostUniFFIV2: + return extractDkgGroupPublicKeyFromUniFFIV2(signerMaterial) + case NativeSignerMaterialFormatFrostTBTCSignerV1: + return extractDkgGroupPublicKeyFromTBTCSignerV1(signerMaterial) + case NativeSignerMaterialFormatFrostUniFFIV1: + return nil, fmt.Errorf( + "%w: %s (migrate to %s or %s before enabling ROAST retry)", + ErrUnsupportedSignerMaterialFormat, + signerMaterial.Format, + NativeSignerMaterialFormatFrostUniFFIV2, + NativeSignerMaterialFormatFrostTBTCSignerV1, + ) + default: + return nil, fmt.Errorf( + "%w: unknown format %q", + ErrUnsupportedSignerMaterialFormat, + signerMaterial.Format, + ) + } +} + +func extractDkgGroupPublicKeyFromUniFFIV2( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + decoded, err := decodeNativeFROSTUniFFIV2SignerMaterial(signerMaterial) + if err != nil { + return nil, fmt.Errorf( + "dkg group public key: decode FrostUniFFIV2: %w", + err, + ) + } + if decoded.PublicKeyPackage == nil { + return nil, fmt.Errorf( + "dkg group public key: FrostUniFFIV2 public key package is nil", + ) + } + verifyingKey := decoded.PublicKeyPackage.VerifyingKey + if verifyingKey == "" { + return nil, fmt.Errorf( + "dkg group public key: FrostUniFFIV2 verifying key is empty", + ) + } + raw, err := hex.DecodeString(verifyingKey) + if err != nil { + return nil, fmt.Errorf( + "dkg group public key: FrostUniFFIV2 verifying key is not hex: %w", + err, + ) + } + return raw, nil +} + +func extractDkgGroupPublicKeyFromTBTCSignerV1( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return nil, fmt.Errorf( + "dkg group public key: decode FrostTBTCSignerV1: %w", + err, + ) + } + if payload.KeyGroup == "" { + return nil, fmt.Errorf( + "dkg group public key: FrostTBTCSignerV1 key group is empty", + ) + } + return []byte(payload.KeyGroup), nil +} diff --git a/pkg/frost/signing/dkg_group_pubkey_extraction_test.go b/pkg/frost/signing/dkg_group_pubkey_extraction_test.go new file mode 100644 index 0000000000..400de0b586 --- /dev/null +++ b/pkg/frost/signing/dkg_group_pubkey_extraction_test.go @@ -0,0 +1,205 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestExtractDkgGroupPublicKey_RejectsNilMaterial(t *testing.T) { + _, err := ExtractDkgGroupPublicKeyFromMaterial(nil) + if err == nil { + t.Fatal("expected error for nil material") + } +} + +func TestExtractDkgGroupPublicKey_FrostUniFFIV2_HexDecodes(t *testing.T) { + const hexKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + payload, err := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "id-1", + Data: []byte{0x01}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: hexKey, + }, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + got, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err != nil { + t.Fatalf("extract: %v", err) + } + want, _ := hex.DecodeString(hexKey) + if !bytes.Equal(got, want) { + t.Fatalf( + "hex decode mismatch: got %x, want %x", + got, want, + ) + } + if len(got) != 32 { + t.Fatalf("expected 32 bytes, got %d", len(got)) + } +} + +func TestExtractDkgGroupPublicKey_FrostUniFFIV2_RejectsEmptyVerifyingKey(t *testing.T) { + payload, _ := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "id-1", + Data: []byte{0x01}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: "", + }, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + // The pre-existing decodeNativeFROSTUniFFIV2SignerMaterial + // validator may reject this before our helper sees it; either + // way an error must be returned. + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err == nil { + t.Fatal("expected error for empty VerifyingKey") + } +} + +func TestExtractDkgGroupPublicKey_FrostUniFFIV2_RejectsNonHexVerifyingKey(t *testing.T) { + payload, _ := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "id-1", + Data: []byte{0x01}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: "not-hex-zzz!", + }, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err == nil { + t.Fatal("expected error for non-hex VerifyingKey") + } + if !strings.Contains(err.Error(), "not hex") { + t.Fatalf("error must mention hex problem; got %v", err) + } +} + +func TestExtractDkgGroupPublicKey_FrostTBTCSignerV1_ReturnsKeyGroupBytes(t *testing.T) { + const keyGroup = "group-A" + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: keyGroup, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + got, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err != nil { + t.Fatalf("extract: %v", err) + } + if string(got) != keyGroup { + t.Fatalf("got %q, want %q", string(got), keyGroup) + } +} + +func TestExtractDkgGroupPublicKey_FrostTBTCSignerV1_DeterministicAcrossCalls(t *testing.T) { + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "deterministic-group", + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + a, _ := ExtractDkgGroupPublicKeyFromMaterial(mat) + b, _ := ExtractDkgGroupPublicKeyFromMaterial(mat) + if !bytes.Equal(a, b) { + t.Fatalf("extraction is non-deterministic: %x vs %x", a, b) + } +} + +func TestExtractDkgGroupPublicKey_FrostTBTCSignerV1_RejectsEmptyKeyGroup(t *testing.T) { + payload, _ := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "", + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err == nil { + t.Fatal("expected error for empty KeyGroup") + } +} + +func TestExtractDkgGroupPublicKey_FrostUniFFIV1_ReturnsUnsupportedSentinel(t *testing.T) { + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte("{}"), + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !strings.Contains(err.Error(), "migrate to") { + t.Fatalf("error must guide operator to migration; got %v", err) + } +} + +func TestExtractDkgGroupPublicKey_UnknownFormat_ReturnsUnsupportedSentinel(t *testing.T) { + mat := &NativeSignerMaterial{ + Format: "frost-some-future-format-v0", + Payload: []byte("{}"), + } + _, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if !errors.Is(err, ErrUnsupportedSignerMaterialFormat) { + t.Fatalf("expected ErrUnsupportedSignerMaterialFormat, got %v", err) + } + if !strings.Contains(err.Error(), "frost-some-future-format-v0") { + t.Fatalf("error must mention the unknown format; got %v", err) + } +} + +func TestExtractDkgGroupPublicKey_FrostUniFFIV2_GoldenFixture(t *testing.T) { + // Lock the canonical byte output for a specific hex input. If a + // future change to extractDkgGroupPublicKeyFromUniFFIV2 alters + // the result, this test catches the drift at code review. + const hexKey = "deadbeefcafebabe0000000000000000000000000000000000000000000000ff" + payload, _ := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "fixture", + Data: []byte{0xFF}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: hexKey, + }, + }) + mat := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + got, err := ExtractDkgGroupPublicKeyFromMaterial(mat) + if err != nil { + t.Fatalf("extract: %v", err) + } + want, _ := hex.DecodeString(hexKey) + if !bytes.Equal(got, want) { + t.Fatalf( + "golden fixture mismatch: got %x, want %x", + got, want, + ) + } +} diff --git a/pkg/frost/signing/evidence_overflow.go b/pkg/frost/signing/evidence_overflow.go new file mode 100644 index 0000000000..60bbe89cc5 --- /dev/null +++ b/pkg/frost/signing/evidence_overflow.go @@ -0,0 +1,43 @@ +//go:build frost_native + +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// senderIndexedMessage is the minimal contract a protocol message must +// satisfy for enqueueOrRecordOverflow to handle it: the message must +// expose its sender so the recorder can attribute overflow events to a +// specific member. +type senderIndexedMessage interface { + SenderID() group.MemberIndex +} + +// enqueueOrRecordOverflow attempts to enqueue payload onto target. If +// the channel is full, the overflow is recorded against the payload's +// sender on the supplied recorder instead. Returns true if the payload +// was enqueued, false if the overflow was recorded. +// +// This is the shared select-or-record body that replaces the three +// inline select { default } drop sites in the FROST/tbtc-signer +// receive loops. Pulling it out lets the recorder integration be unit- +// tested directly without spinning up a network channel. +// +// Phase 2 callers pass attempt.NoOpRecorder(), so behaviour is +// observably unchanged from before RFC-21 wiring. A coordinator-aware +// caller in a later phase injects a real recorder. +func enqueueOrRecordOverflow[T senderIndexedMessage]( + payload T, + target chan<- T, + recorder attempt.EvidenceRecorder, +) bool { + select { + case target <- payload: + return true + default: + recorder.RecordOverflow(payload.SenderID()) + return false + } +} diff --git a/pkg/frost/signing/evidence_overflow_test.go b/pkg/frost/signing/evidence_overflow_test.go new file mode 100644 index 0000000000..2b1f79e567 --- /dev/null +++ b/pkg/frost/signing/evidence_overflow_test.go @@ -0,0 +1,154 @@ +//go:build frost_native + +package signing + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestEnqueueOrRecordOverflow_EnqueuesWhenChannelHasRoom(t *testing.T) { + ch := make(chan *nativeFROSTRoundOneCommitmentMessage, 4) + rec := attempt.NewBoundedRecorder() + payload := &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 1} + + if !enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should succeed when channel has room") + } + if got := rec.Snapshot().Overflows[1]; got != 0 { + t.Fatalf("no overflow expected on successful enqueue; got %d", got) + } + if len(ch) != 1 { + t.Fatalf("channel length expected 1, got %d", len(ch)) + } +} + +func TestEnqueueOrRecordOverflow_RecordsOverflowWhenChannelIsFull(t *testing.T) { + ch := make(chan *nativeFROSTRoundOneCommitmentMessage, 1) + ch <- &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 99} // fill it + rec := attempt.NewBoundedRecorder() + + payload := &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 7} + if enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should fail when channel is full") + } + if got := rec.Snapshot().Overflows[7]; got != 1 { + t.Fatalf( + "overflow should be recorded against sender 7; got count %d", + got, + ) + } + if got := rec.Snapshot().Overflows[99]; got != 0 { + t.Fatal( + "sender 99 is the pre-filled payload's sender, not the overflow sender", + ) + } +} + +func TestEnqueueOrRecordOverflow_NoOpRecorderHasNoObservableEffect(t *testing.T) { + ch := make(chan *nativeFROSTRoundOneCommitmentMessage, 1) + ch <- &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 1} + rec := attempt.NoOpRecorder() + + payload := &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 7} + if enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should fail when channel is full") + } + if got := rec.Snapshot().Overflows[7]; got != 0 { + t.Fatalf( + "NoOp recorder must show zero overflow count even when called; got %d", + got, + ) + } +} + +func TestEnqueueOrRecordOverflow_WorksForRoundTwoMessages(t *testing.T) { + ch := make(chan *nativeFROSTRoundTwoSignatureShareMessage, 1) + ch <- &nativeFROSTRoundTwoSignatureShareMessage{SenderIDValue: 1} + rec := attempt.NewBoundedRecorder() + + payload := &nativeFROSTRoundTwoSignatureShareMessage{SenderIDValue: 4} + if enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should fail when channel is full") + } + if got := rec.Snapshot().Overflows[4]; got != 1 { + t.Fatalf("expected overflow count 1 for sender 4, got %d", got) + } +} + +func TestEnqueueOrRecordOverflow_WorksForTBTCSignerContributionMessages(t *testing.T) { + ch := make(chan *buildTaggedTBTCSignerRoundContributionMessage, 1) + ch <- &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 1} + rec := attempt.NewBoundedRecorder() + + payload := &buildTaggedTBTCSignerRoundContributionMessage{SenderIDValue: 5} + if enqueueOrRecordOverflow(payload, ch, rec) { + t.Fatal("enqueue should fail when channel is full") + } + if got := rec.Snapshot().Overflows[5]; got != 1 { + t.Fatalf("expected overflow count 1 for sender 5, got %d", got) + } +} + +func TestEnqueueOrRecordOverflow_RepeatedOverflowsSaturateAtQuota(t *testing.T) { + ch := make(chan *nativeFROSTRoundOneCommitmentMessage, 1) + ch <- &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 1} + rec := attempt.NewBoundedRecorderWithQuota(3) + + for i := 0; i < 10; i++ { + _ = enqueueOrRecordOverflow( + &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 2}, + ch, + rec, + ) + } + if got := rec.Snapshot().Overflows[2]; got != 3 { + t.Fatalf("expected saturation at quota 3, got %d", got) + } +} + +func TestEnqueueOrRecordOverflow_ConcurrentCallersAreRaceSafe(t *testing.T) { + const numProducers = 8 + const recordsPerProducer = 100 + ch := make(chan *nativeFROSTRoundOneCommitmentMessage, 1) + ch <- &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: 1} // fill it once + rec := attempt.NewBoundedRecorderWithQuota(uint(numProducers * recordsPerProducer)) + + var wg sync.WaitGroup + for p := 0; p < numProducers; p++ { + wg.Add(1) + sender := group.MemberIndex(p + 2) + go func() { + defer wg.Done() + for i := 0; i < recordsPerProducer; i++ { + _ = enqueueOrRecordOverflow( + &nativeFROSTRoundOneCommitmentMessage{SenderIDValue: uint32(sender)}, + ch, + rec, + ) + } + }() + } + wg.Wait() + + snap := rec.Snapshot() + totalRecorded := uint(0) + for _, v := range snap.Overflows { + totalRecorded += v + } + // Every producer's records either enqueued (replacing previously- + // dequeued items, but there's no consumer here so the channel stays + // full and all subsequent enqueue attempts fall to the default + // branch) or recorded. Since the channel starts pre-filled and has + // no consumer, all 800 records hit the overflow path. + const expected = numProducers * recordsPerProducer + if totalRecorded != expected { + t.Fatalf( + "concurrent overflow count: got %d, want %d (sum across senders)", + totalRecorded, expected, + ) + } +} diff --git a/pkg/frost/signing/legacy_backend.go b/pkg/frost/signing/legacy_backend.go new file mode 100644 index 0000000000..57b357ea83 --- /dev/null +++ b/pkg/frost/signing/legacy_backend.go @@ -0,0 +1,88 @@ +package signing + +import ( + "context" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + legacySigning "github.com/keep-network/keep-core/pkg/tecdsa/signing" +) + +const legacyExecutionBackendName = "legacy-tecdsa-bridge" + +type legacyExecutionBackend struct{} + +func newLegacyExecutionBackend() *legacyExecutionBackend { + return &legacyExecutionBackend{} +} + +func (leb *legacyExecutionBackend) Name() string { + return legacyExecutionBackendName +} + +func (leb *legacyExecutionBackend) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Attempt != nil { + logger.Infof( + "[member:%v] executing FROST signing attempt [%v] "+ + "with coordinator [%v] (included: [%v], excluded: [%v])", + request.MemberIndex, + request.Attempt.Number, + request.Attempt.CoordinatorMemberIndex, + request.Attempt.IncludedMembersIndexes, + request.Attempt.ExcludedMembersIndexes, + ) + } + + excludedMembersIndexes := []group.MemberIndex{} + if request.Attempt != nil { + excludedMembersIndexes = request.Attempt.ExcludedMembersIndexes + } + + privateKeyShare, err := request.LegacyPrivateKeyShare() + if err != nil { + return nil, err + } + + legacyResult, err := legacySigning.Execute( + ctx, + logger, + request.Message, + request.SessionID, + request.MemberIndex, + privateKeyShare, + request.GroupSize, + request.DishonestThreshold, + excludedMembersIndexes, + request.Channel, + request.MembershipValidator, + ) + if err != nil { + return nil, err + } + + signature, err := FromTECDSASignature(legacyResult.Signature) + if err != nil { + return nil, err + } + + return &Result{ + Signature: signature, + Attempt: cloneAttempt(request.Attempt), + }, nil +} + +func (leb *legacyExecutionBackend) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + legacySigning.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_adapter_build_default_test.go b/pkg/frost/signing/native_adapter_build_default_test.go new file mode 100644 index 0000000000..c9c244292f --- /dev/null +++ b/pkg/frost/signing/native_adapter_build_default_test.go @@ -0,0 +1,26 @@ +//go:build !frost_native + +package signing + +import ( + "errors" + "testing" +) + +func TestNativeExecutionBackend_DefaultBuildUnavailable(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + err := SetExecutionBackendByName("native") + if err == nil { + t.Fatal("expected native backend unavailable error") + } + + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected native backend error\nexpected: [%v]\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } +} diff --git a/pkg/frost/signing/native_adapter_build_frost_native_test.go b/pkg/frost/signing/native_adapter_build_frost_native_test.go new file mode 100644 index 0000000000..e8864a619c --- /dev/null +++ b/pkg/frost/signing/native_adapter_build_frost_native_test.go @@ -0,0 +1,474 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +type mockNativeExecutionBridge struct { + available bool + + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +func (mneb *mockNativeExecutionBridge) IsAvailable() bool { + return mneb.available +} + +func (mneb *mockNativeExecutionBridge) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + mneb.executeCalls++ + mneb.lastRequest = request + return mneb.result, mneb.err +} + +func (mneb *mockNativeExecutionBridge) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mneb.registerUnmarshallersCalls++ + mneb.lastChannel = channel +} + +func staticNativeBridgeProvider( + bridge NativeExecutionBridge, +) func() NativeExecutionBridge { + return func() NativeExecutionBridge { + return bridge + } +} + +func TestNativeExecutionBackend_FrostNativeBuildSelectable(t *testing.T) { + ResetExecutionBackend() + UnregisterNativeExecutionAdapter() + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + RegisterNativeExecutionAdapterForBuild() + t.Cleanup(ResetExecutionBackend) + t.Cleanup(UnregisterNativeExecutionAdapter) + t.Cleanup(UnregisterNativeExecutionBridge) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := SetExecutionBackendByName("native") + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if CurrentExecutionBackendName() != NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + NativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } + + adapter := newBuildTaggedNativeExecutionAdapter() + + _, err = adapter.Execute(context.Background(), nil, nil) + if err == nil { + t.Fatal("expected request validation error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected native execution error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } + + err = SetExecutionBackendByName("ffi") + if err != nil { + t.Fatalf( + "unexpected strict ffi backend config error\nexpected: [nil]\nactual: [%v]", + err, + ) + } + + UnregisterNativeExecutionBridge() + UnregisterNativeExecutionFFIExecutor() + + err = SetExecutionBackendByName("ffi") + if err == nil { + t.Fatal("expected strict ffi backend unavailable error") + } + + if !errors.Is(err, ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected ffi backend error\nexpected: [%v]\nactual: [%v]", + ErrNativeExecutionBackendUnavailable, + err, + ) + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected ffi native-availability error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + registeredBridge := &mockNativeExecutionBridge{ + available: true, + result: &Result{}, + } + + err = RegisterNativeExecutionBridge(registeredBridge) + if err != nil { + t.Fatalf("failed registering native execution bridge: [%v]", err) + } + + err = SetExecutionBackendByName("ffi") + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + if CurrentExecutionBackendName() != NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name for strict ffi config\nexpected: [%s]\nactual: [%s]", + NativeExecutionBackendName, + CurrentExecutionBackendName(), + ) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_UsesNativeBridgeWhenAvailable( + t *testing.T, +) { + expectedResult := &Result{} + bridge := &mockNativeExecutionBridge{ + available: true, + result: expectedResult, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + result, err := adapter.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if bridge.executeCalls != 1 { + t.Fatalf("unexpected bridge execute calls count: [%d]", bridge.executeCalls) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_FallsBackWhenBridgeUnavailable( + t *testing.T, +) { + expectedResult := &Result{} + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + result, err := adapter.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if bridge.executeCalls != 0 { + t.Fatalf("unexpected bridge execute calls count: [%d]", bridge.executeCalls) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_FallsBackOnUnavailableBridgeError( + t *testing.T, +) { + expectedResult := &Result{} + bridge := &mockNativeExecutionBridge{ + available: true, + err: ErrNativeCryptographyUnavailable, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + result, err := adapter.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if bridge.executeCalls != 1 { + t.Fatalf("unexpected bridge execute calls count: [%d]", bridge.executeCalls) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_ReturnsBridgeError( + t *testing.T, +) { + bridgeError := errors.New("bridge failure") + bridge := &mockNativeExecutionBridge{ + available: true, + err: bridgeError, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + _, err := adapter.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, bridgeError) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + bridgeError, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_StrictModeNoFallbackWhenUnavailable( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + _, err := adapter.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_Execute_StrictModeNoFallbackOnUnavailableError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + bridge := &mockNativeExecutionBridge{ + available: true, + err: ErrNativeCryptographyUnavailable, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + _, err := adapter.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionAdapter_RegisterUnmarshallers_UsesNativeWhenAvailable( + t *testing.T, +) { + bridge := &mockNativeExecutionBridge{ + available: true, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + adapter.RegisterUnmarshallers(nil) + + if bridge.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected bridge register unmarshallers calls count: [%d]", + bridge.registerUnmarshallersCalls, + ) + } + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} + +func TestBuildTaggedNativeExecutionAdapter_RegisterUnmarshallers_FallsBackWhenUnavailable( + t *testing.T, +) { + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + adapter.RegisterUnmarshallers(nil) + + if bridge.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected bridge register unmarshallers calls count: [%d]", + bridge.registerUnmarshallersCalls, + ) + } + + if fallback.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} + +func TestBuildTaggedNativeExecutionAdapter_RegisterUnmarshallers_StrictModeNoFallback( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + bridge := &mockNativeExecutionBridge{ + available: false, + } + + fallback := &mockExecutionBackend{name: "fallback"} + + adapter := &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: staticNativeBridgeProvider(bridge), + fallback: fallback, + } + + adapter.RegisterUnmarshallers(nil) + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} diff --git a/pkg/frost/signing/native_adapter_registration.go b/pkg/frost/signing/native_adapter_registration.go new file mode 100644 index 0000000000..c4774da5a9 --- /dev/null +++ b/pkg/frost/signing/native_adapter_registration.go @@ -0,0 +1,5 @@ +package signing + +func init() { + RegisterNativeExecutionAdapterForBuild() +} diff --git a/pkg/frost/signing/native_adapter_registration_default.go b/pkg/frost/signing/native_adapter_registration_default.go new file mode 100644 index 0000000000..065342b1cc --- /dev/null +++ b/pkg/frost/signing/native_adapter_registration_default.go @@ -0,0 +1,5 @@ +//go:build !frost_native + +package signing + +func registerNativeExecutionAdapterForBuild() {} diff --git a/pkg/frost/signing/native_adapter_registration_frost_native.go b/pkg/frost/signing/native_adapter_registration_frost_native.go new file mode 100644 index 0000000000..313971394e --- /dev/null +++ b/pkg/frost/signing/native_adapter_registration_frost_native.go @@ -0,0 +1,142 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// buildTaggedNativeExecutionAdapter is a transitional adapter wired when the +// frost_native build tag is enabled. +// +// The adapter uses a native execution bridge when available. +// +// Backend mode behavior: +// - `native`: fallback to legacy bridge when native cryptography is unavailable +// - `ffi`: no fallback; native cryptographic execution is required +type buildTaggedNativeExecutionAdapter struct { + nativeBridgeProvider func() NativeExecutionBridge + fallback ExecutionBackend +} + +func registerNativeExecutionAdapterForBuild() { + // Registration errors are surfaced via `LastNativeRegistrationError()` + // rather than panicking, so a transient registration failure at init time + // does not crash the binary. `currentNativeExecutionBackend()` already + // reports `ErrNativeCryptographyUnavailable` when no native adapter is + // registered, which keeps the legacy execution backend as the safe-by- + // default fallback. + err := RegisterNativeExecutionBridge(newBuildTaggedNativeExecutionBridge()) + if err != nil { + registrationLogger.Warnf( + "failed to register build-tagged native bridge: [%v]; "+ + "native execution will report unavailable and the legacy "+ + "execution backend remains the safe-by-default path", + err, + ) + setLastRegistrationError(fmt.Errorf( + "failed to register build-tagged native bridge: [%w]", + err, + )) + return + } + + err = RegisterNativeExecutionAdapter(newBuildTaggedNativeExecutionAdapter()) + if err != nil { + registrationLogger.Warnf( + "failed to register build-tagged native adapter: [%v]; "+ + "native execution will report unavailable and the legacy "+ + "execution backend remains the safe-by-default path", + err, + ) + setLastRegistrationError(fmt.Errorf( + "failed to register build-tagged native adapter: [%w]", + err, + )) + return + } +} + +func newBuildTaggedNativeExecutionAdapter() *buildTaggedNativeExecutionAdapter { + return &buildTaggedNativeExecutionAdapter{ + nativeBridgeProvider: newNativeExecutionBridge, + fallback: newLegacyExecutionBackend(), + } +} + +func (btnea *buildTaggedNativeExecutionAdapter) NativeExecutionAvailable() bool { + nativeBridge := btnea.currentNativeBridge() + return nativeBridge != nil && nativeBridge.IsAvailable() +} + +func (btnea *buildTaggedNativeExecutionAdapter) currentNativeBridge() NativeExecutionBridge { + if btnea.nativeBridgeProvider == nil { + return nil + } + + return btnea.nativeBridgeProvider() +} + +func (btnea *buildTaggedNativeExecutionAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + nativeBridge := btnea.currentNativeBridge() + if nativeBridge != nil && nativeBridge.IsAvailable() { + result, err := nativeBridge.Execute(ctx, logger, request) + if err == nil { + return result, nil + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + return nil, fmt.Errorf("native bridge execution failed: [%w]", err) + } + + if !nativeExecutionFallbackAllowed() { + return nil, err + } + + if logger != nil { + logger.Warnf( + "native FROST cryptography unavailable; falling back to legacy bridge backend: [%v]", + err, + ) + } + } + + if !nativeExecutionFallbackAllowed() { + return nil, ErrNativeCryptographyUnavailable + } + + if btnea.fallback == nil { + return nil, fmt.Errorf("fallback execution backend is nil") + } + + return btnea.fallback.Execute(ctx, logger, request) +} + +func (btnea *buildTaggedNativeExecutionAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + nativeBridge := btnea.currentNativeBridge() + if nativeBridge != nil && nativeBridge.IsAvailable() { + nativeBridge.RegisterUnmarshallers(channel) + return + } + + if !nativeExecutionFallbackAllowed() { + return + } + + if btnea.fallback == nil { + return + } + + btnea.fallback.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_backend.go b/pkg/frost/signing/native_backend.go new file mode 100644 index 0000000000..a909ed9b21 --- /dev/null +++ b/pkg/frost/signing/native_backend.go @@ -0,0 +1,60 @@ +package signing + +import ( + "context" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +const nativeExecutionBackendName = "native-frost-ffi" + +// NativeExecutionAdapter is a transitional hook for wiring a future native +// FROST signing implementation (for example, cgo/FFI-backed). +type NativeExecutionAdapter interface { + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +type nativeExecutionBackend struct { + adapter NativeExecutionAdapter +} + +func newNativeExecutionBackend( + adapter NativeExecutionAdapter, +) (*nativeExecutionBackend, error) { + if adapter == nil { + return nil, fmt.Errorf("native execution adapter is nil") + } + + return &nativeExecutionBackend{ + adapter: adapter, + }, nil +} + +func (neb *nativeExecutionBackend) Name() string { + return nativeExecutionBackendName +} + +func (neb *nativeExecutionBackend) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + return neb.adapter.Execute(ctx, logger, request) +} + +func (neb *nativeExecutionBackend) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + neb.adapter.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_bridge.go b/pkg/frost/signing/native_bridge.go new file mode 100644 index 0000000000..195369aed9 --- /dev/null +++ b/pkg/frost/signing/native_bridge.go @@ -0,0 +1,100 @@ +package signing + +import ( + "context" + "errors" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +var ( + // ErrNativeCryptographyUnavailable indicates that native FROST + // cryptographic execution is not linked in the current build. + // + // The frost_native adapter handles this condition by falling back to the + // legacy bridge backend. + ErrNativeCryptographyUnavailable = errors.New( + "native FROST cryptographic execution is unavailable", + ) + // ErrNativeBridgeOperationFailed indicates that native cryptographic + // execution is available but a bridge operation returned a non-success + // status. This error should not trigger availability fallback. + ErrNativeBridgeOperationFailed = errors.New( + "native FROST bridge operation failed", + ) +) + +// NativeExecutionBridge defines a native cryptographic execution entrypoint +// used by the frost_native adapter. +// +// The current implementation returns ErrNativeCryptographyUnavailable. Future +// FFI-backed integrations should provide an available bridge implementation. +type NativeExecutionBridge interface { + IsAvailable() bool + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +// RegisterNativeExecutionBridge registers a native execution bridge for +// frost_native adapter routing. +func RegisterNativeExecutionBridge(bridge NativeExecutionBridge) error { + if bridge == nil { + return errors.New("native execution bridge is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + registeredNativeExecBridge = bridge + + return nil +} + +// UnregisterNativeExecutionBridge clears the registered native execution +// bridge. +func UnregisterNativeExecutionBridge() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + registeredNativeExecBridge = nil +} + +func currentNativeExecutionBridge() NativeExecutionBridge { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return registeredNativeExecBridge +} + +func newNativeExecutionBridge() NativeExecutionBridge { + bridge := currentNativeExecutionBridge() + if bridge != nil { + return bridge + } + + return &unlinkedNativeExecutionBridge{} +} + +type unlinkedNativeExecutionBridge struct{} + +func (uneb *unlinkedNativeExecutionBridge) IsAvailable() bool { + return false +} + +func (uneb *unlinkedNativeExecutionBridge) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + return nil, ErrNativeCryptographyUnavailable +} + +func (uneb *unlinkedNativeExecutionBridge) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} diff --git a/pkg/frost/signing/native_bridge_frost_native.go b/pkg/frost/signing/native_bridge_frost_native.go new file mode 100644 index 0000000000..1cb5e9d186 --- /dev/null +++ b/pkg/frost/signing/native_bridge_frost_native.go @@ -0,0 +1,104 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "fmt" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// buildTaggedNativeExecutionBridge is a transitional native bridge registered +// for frost_native builds. +// +// Until a real FFI-backed bridge is linked, this bridge delegates to the +// legacy signing backend while still surfacing native-bridge availability. +type buildTaggedNativeExecutionBridge struct { + ffiExecutorProvider func() NativeExecutionFFIExecutor + delegate ExecutionBackend +} + +func newBuildTaggedNativeExecutionBridge() NativeExecutionBridge { + return &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: currentNativeExecutionFFIExecutor, + delegate: newLegacyExecutionBackend(), + } +} + +func (btneb *buildTaggedNativeExecutionBridge) IsAvailable() bool { + if btneb.currentFFIExecutor() != nil { + return true + } + + return nativeExecutionFallbackAllowed() && btneb.delegate != nil +} + +func (btneb *buildTaggedNativeExecutionBridge) currentFFIExecutor() NativeExecutionFFIExecutor { + if btneb.ffiExecutorProvider == nil { + return nil + } + + return btneb.ffiExecutorProvider() +} + +func (btneb *buildTaggedNativeExecutionBridge) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + ffiExecutor := btneb.currentFFIExecutor() + if ffiExecutor != nil { + result, err := ffiExecutor.Execute(ctx, logger, request) + if err == nil { + return result, nil + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + return nil, fmt.Errorf("native FFI executor execution failed: [%w]", err) + } + + if !nativeExecutionFallbackAllowed() { + return nil, err + } + + if logger != nil { + logger.Warnf( + "native FFI executor unavailable; falling back to legacy bridge backend: [%v]", + err, + ) + } + } + + if !nativeExecutionFallbackAllowed() { + return nil, ErrNativeCryptographyUnavailable + } + + if btneb.delegate == nil { + return nil, ErrNativeCryptographyUnavailable + } + + return btneb.delegate.Execute(ctx, logger, request) +} + +func (btneb *buildTaggedNativeExecutionBridge) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + ffiExecutor := btneb.currentFFIExecutor() + if ffiExecutor != nil { + ffiExecutor.RegisterUnmarshallers(channel) + return + } + + if !nativeExecutionFallbackAllowed() { + return + } + + if btneb.delegate == nil { + return + } + + btneb.delegate.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_bridge_frost_native_test.go b/pkg/frost/signing/native_bridge_frost_native_test.go new file mode 100644 index 0000000000..0608b743ac --- /dev/null +++ b/pkg/frost/signing/native_bridge_frost_native_test.go @@ -0,0 +1,365 @@ +//go:build frost_native + +package signing + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +type mockNativeExecutionFFIExecutor struct { + executeCalls int + lastRequest *Request + result *Result + err error + + registerUnmarshallersCalls int + lastChannel net.BroadcastChannel +} + +func (mnefe *mockNativeExecutionFFIExecutor) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + mnefe.executeCalls++ + mnefe.lastRequest = request + return mnefe.result, mnefe.err +} + +func (mnefe *mockNativeExecutionFFIExecutor) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mnefe.registerUnmarshallersCalls++ + mnefe.lastChannel = channel +} + +func staticNativeFFIExecutorProvider( + executor NativeExecutionFFIExecutor, +) func() NativeExecutionFFIExecutor { + return func() NativeExecutionFFIExecutor { + return executor + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_UsesFFIExecutor( + t *testing.T, +) { + expectedResult := &Result{} + ffiExecutor := &mockNativeExecutionFFIExecutor{ + result: expectedResult, + } + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + result, err := bridge.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_StrictNoFallbackWithoutFFIExecutor( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(nil), + delegate: fallback, + } + + _, err := bridge.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_FallsBackWithoutFFIExecutor( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + + expectedResult := &Result{} + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(nil), + delegate: fallback, + } + + result, err := bridge.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_StrictNoFallbackOnFFIUnavailableError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + ffiExecutor := &mockNativeExecutionFFIExecutor{ + err: ErrNativeCryptographyUnavailable, + } + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + _, err := bridge.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected execute error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_FallsBackOnFFIUnavailableError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + expectedResult := &Result{} + ffiExecutor := &mockNativeExecutionFFIExecutor{ + err: ErrNativeCryptographyUnavailable, + } + fallback := &mockExecutionBackend{ + name: "fallback", + result: expectedResult, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + result, err := bridge.Execute(context.Background(), nil, &Request{}) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result != expectedResult { + t.Fatalf( + "unexpected result\nexpected: [%+v]\nactual: [%+v]", + expectedResult, + result, + ) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 1 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_Execute_NoFallbackOnFFIExecutionError( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + ffiExecutionError := errors.New("ffi executor crashed") + ffiExecutor := &mockNativeExecutionFFIExecutor{ + err: ffiExecutionError, + } + fallback := &mockExecutionBackend{ + name: "fallback", + result: &Result{}, + } + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + _, err := bridge.Execute(context.Background(), nil, &Request{}) + if err == nil { + t.Fatal("expected execute error") + } + + if !errors.Is(err, ffiExecutionError) { + t.Fatalf( + "unexpected execute error\nexpected to wrap: [%v]\nactual: [%v]", + ffiExecutionError, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected availability error wrapping for non-availability failure: [%v]", + err, + ) + } + + if !strings.Contains(err.Error(), "native FFI executor execution failed") { + t.Fatalf("unexpected error message: [%v]", err) + } + + if ffiExecutor.executeCalls != 1 { + t.Fatalf( + "unexpected ffi executor execute calls count: [%d]", + ffiExecutor.executeCalls, + ) + } + + if fallback.executeCalls != 0 { + t.Fatalf("unexpected fallback execute calls count: [%d]", fallback.executeCalls) + } +} + +func TestBuildTaggedNativeExecutionBridge_RegisterUnmarshallers_UsesFFIExecutor( + t *testing.T, +) { + ffiExecutor := &mockNativeExecutionFFIExecutor{} + fallback := &mockExecutionBackend{name: "fallback"} + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(ffiExecutor), + delegate: fallback, + } + + bridge.RegisterUnmarshallers(nil) + + if ffiExecutor.registerUnmarshallersCalls != 1 { + t.Fatalf( + "unexpected ffi executor register unmarshallers calls count: [%d]", + ffiExecutor.registerUnmarshallersCalls, + ) + } + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} + +func TestBuildTaggedNativeExecutionBridge_RegisterUnmarshallers_StrictNoFallback( + t *testing.T, +) { + setNativeExecutionMode(nativeExecutionModeStrict) + t.Cleanup(func() { + setNativeExecutionMode(nativeExecutionModeFallbackAllowed) + }) + + fallback := &mockExecutionBackend{name: "fallback"} + + bridge := &buildTaggedNativeExecutionBridge{ + ffiExecutorProvider: staticNativeFFIExecutorProvider(nil), + delegate: fallback, + } + + bridge.RegisterUnmarshallers(nil) + + if fallback.registerUnmarshallersCalls != 0 { + t.Fatalf( + "unexpected fallback register unmarshallers calls count: [%d]", + fallback.registerUnmarshallersCalls, + ) + } +} diff --git a/pkg/frost/signing/native_ffi_executor.go b/pkg/frost/signing/native_ffi_executor.go new file mode 100644 index 0000000000..fe45850d9f --- /dev/null +++ b/pkg/frost/signing/native_ffi_executor.go @@ -0,0 +1,51 @@ +package signing + +import ( + "context" + "errors" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/net" +) + +// NativeExecutionFFIExecutor is a bridge to the native/FFI signing engine. +// This executor is intended to run FROST-native cryptographic execution. +type NativeExecutionFFIExecutor interface { + Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, + ) (*Result, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +// RegisterNativeExecutionFFIExecutor registers a native FFI executor used by +// build-tagged bridges. +func RegisterNativeExecutionFFIExecutor(executor NativeExecutionFFIExecutor) error { + if executor == nil { + return errors.New("native execution FFI executor is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFIExecutor = executor + + return nil +} + +// UnregisterNativeExecutionFFIExecutor clears the native FFI executor +// registration. +func UnregisterNativeExecutionFFIExecutor() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFIExecutor = nil +} + +func currentNativeExecutionFFIExecutor() NativeExecutionFFIExecutor { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionFFIExecutor +} diff --git a/pkg/frost/signing/native_ffi_executor_adapter.go b/pkg/frost/signing/native_ffi_executor_adapter.go new file mode 100644 index 0000000000..4ff3d486ea --- /dev/null +++ b/pkg/frost/signing/native_ffi_executor_adapter.go @@ -0,0 +1,139 @@ +package signing + +import ( + "context" + "fmt" + "math/big" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// NativeExecutionFFISigningRequest is the canonical request passed to a native +// FFI signing primitive. +type NativeExecutionFFISigningRequest struct { + Message *big.Int + SessionID string + MemberIndex group.MemberIndex + GroupSize int + DishonestThreshold int + Channel net.BroadcastChannel + MembershipValidator *group.MembershipValidator + SignerMaterial *NativeSignerMaterial + Attempt *Attempt +} + +// NativeExecutionFFISigningPrimitive is a minimal cryptographic primitive +// interface used by the reusable native FFI executor adapter. +type NativeExecutionFFISigningPrimitive interface { + Sign( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + ) (*frost.Signature, error) + RegisterUnmarshallers(channel net.BroadcastChannel) +} + +type nativeExecutionFFIExecutorAdapter struct { + primitive NativeExecutionFFISigningPrimitive +} + +// NewNativeExecutionFFIExecutorAdapter wraps a native FFI signing primitive as +// a NativeExecutionFFIExecutor. +func NewNativeExecutionFFIExecutorAdapter( + primitive NativeExecutionFFISigningPrimitive, +) (NativeExecutionFFIExecutor, error) { + if primitive == nil { + return nil, fmt.Errorf("native execution FFI signing primitive is nil") + } + + return &nativeExecutionFFIExecutorAdapter{ + primitive: primitive, + }, nil +} + +// RegisterNativeExecutionFFISigningPrimitive registers a native FFI signing +// primitive by adapting it to NativeExecutionFFIExecutor. +func RegisterNativeExecutionFFISigningPrimitive( + primitive NativeExecutionFFISigningPrimitive, +) error { + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + return err + } + + return RegisterNativeExecutionFFIExecutor(executor) +} + +func (nefea *nativeExecutionFFIExecutorAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Message == nil { + return nil, fmt.Errorf("request message is nil") + } + + signerMaterial, err := request.NativeSignerMaterial() + if err != nil { + return nil, fmt.Errorf("%w: [%v]", ErrNativeCryptographyUnavailable, err) + } + + ffiRequest := &NativeExecutionFFISigningRequest{ + Message: request.Message, + SessionID: request.SessionID, + MemberIndex: request.MemberIndex, + GroupSize: request.GroupSize, + DishonestThreshold: request.DishonestThreshold, + Channel: request.Channel, + MembershipValidator: request.MembershipValidator, + SignerMaterial: signerMaterial, + Attempt: cloneAttempt(request.Attempt), + } + + // RFC-21 Phase 6.3: ROAST orchestration entry. The helper + // returns (cleanup, error): + // - cleanup non-nil, error nil -> orchestration active; + // defer cleanup so success and failure return paths converge. + // - cleanup nil, error nil -> static-configuration fallback + // (env var unset, no coordinator registered, or material + // format not extractable). Proceed without orchestration; the + // receive loops use NoOp recorder semantics (Phase 5 behaviour). + // - cleanup nil, error non-nil -> RUNTIME orchestration failure. + // HARD FAIL to prevent group fracture across honest signers. + // In the default build (no frost_native tag) the helper is a + // permanent no-op returning (nil, nil). + orchCleanup, orchErr := attemptRoastRetryOrchestrationFromRequest(ffiRequest, logger) + if orchErr != nil { + return nil, orchErr + } + if orchCleanup != nil { + defer orchCleanup() + } + + signature, err := nefea.primitive.Sign(ctx, logger, ffiRequest) + if err != nil { + return nil, err + } + + if signature == nil { + return nil, fmt.Errorf("native FFI signing primitive returned nil signature") + } + + return &Result{ + Signature: signature, + Attempt: cloneAttempt(request.Attempt), + }, nil +} + +func (nefea *nativeExecutionFFIExecutorAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + nefea.primitive.RegisterUnmarshallers(channel) +} diff --git a/pkg/frost/signing/native_ffi_executor_adapter_test.go b/pkg/frost/signing/native_ffi_executor_adapter_test.go new file mode 100644 index 0000000000..565e5eaaf5 --- /dev/null +++ b/pkg/frost/signing/native_ffi_executor_adapter_test.go @@ -0,0 +1,316 @@ +package signing + +import ( + "context" + "errors" + "math/big" + "strings" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type mockNativeExecutionFFISigningPrimitive struct { + signCalls int + lastRequest *NativeExecutionFFISigningRequest + signature *frost.Signature + signErr error + registerCalls int + lastChannel net.BroadcastChannel +} + +func (mnefsp *mockNativeExecutionFFISigningPrimitive) Sign( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + mnefsp.signCalls++ + mnefsp.lastRequest = request + return mnefsp.signature, mnefsp.signErr +} + +func (mnefsp *mockNativeExecutionFFISigningPrimitive) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + mnefsp.registerCalls++ + mnefsp.lastChannel = channel +} + +func TestNewNativeExecutionFFIExecutorAdapter_NilPrimitive(t *testing.T) { + _, err := NewNativeExecutionFFIExecutorAdapter(nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native execution FFI signing primitive is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native execution FFI signing primitive is nil", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_ValidatesRequest(t *testing.T) { + executor, err := NewNativeExecutionFFIExecutorAdapter( + &mockNativeExecutionFFISigningPrimitive{}, + ) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_ValidatesMessage(t *testing.T) { + executor, err := NewNativeExecutionFFIExecutorAdapter( + &mockNativeExecutionFFISigningPrimitive{}, + ) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + SignerMaterial: []byte{0x01}, + }) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request message is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request message is nil", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_ValidatesSignerMaterial( + t *testing.T, +) { + executor, err := NewNativeExecutionFFIExecutorAdapter( + &mockNativeExecutionFFISigningPrimitive{}, + ) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(1), + SignerMaterial: "invalid", + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !strings.Contains(err.Error(), "native signer material has wrong type") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material has wrong type", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_DelegatesToPrimitive( + t *testing.T, +) { + expectedSignature := &frost.Signature{ + R: [frost.SignatureComponentSize]byte{0x01}, + S: [frost.SignatureComponentSize]byte{0x02}, + } + + primitive := &mockNativeExecutionFFISigningPrimitive{ + signature: expectedSignature, + } + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + attempt := &Attempt{ + Number: 3, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3}, + ExcludedMembersIndexes: []group.MemberIndex{4}, + } + + result, err := executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 2, + GroupSize: 5, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0xaa}, + }, + Attempt: attempt, + }) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if result == nil || result.Signature != expectedSignature { + t.Fatalf( + "unexpected result signature\nexpected: [%+v]\nactual: [%+v]", + expectedSignature, + result, + ) + } + + if primitive.signCalls != 1 { + t.Fatalf("unexpected primitive sign calls count: [%d]", primitive.signCalls) + } + + if primitive.lastRequest == nil { + t.Fatal("expected primitive request") + } + + if primitive.lastRequest.SignerMaterial == nil { + t.Fatal("expected signer material in primitive request") + } + + if primitive.lastRequest.Attempt == attempt { + t.Fatal("expected attempt clone in primitive request") + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_PropagatesPrimitiveError( + t *testing.T, +) { + expectedErr := errors.New("native signer failure") + primitive := &mockNativeExecutionFFISigningPrimitive{ + signErr: expectedErr, + } + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(1), + SignerMaterial: []byte{0x01}, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, expectedErr) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + expectedErr, + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_Execute_RejectsNilSignature( + t *testing.T, +) { + primitive := &mockNativeExecutionFFISigningPrimitive{} + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + _, err = executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(1), + SignerMaterial: []byte{0x01}, + }) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "returned nil signature") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "returned nil signature", + err, + ) + } +} + +func TestNativeExecutionFFIExecutorAdapter_RegisterUnmarshallers_Delegates( + t *testing.T, +) { + primitive := &mockNativeExecutionFFISigningPrimitive{} + + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatalf("unexpected adapter setup error: [%v]", err) + } + + var channel net.BroadcastChannel + executor.RegisterUnmarshallers(channel) + + if primitive.registerCalls != 1 { + t.Fatalf( + "unexpected register unmarshallers calls count: [%d]", + primitive.registerCalls, + ) + } +} + +func TestRegisterNativeExecutionFFISigningPrimitive_Nil(t *testing.T) { + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := RegisterNativeExecutionFFISigningPrimitive(nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native execution FFI signing primitive is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native execution FFI signing primitive is nil", + err, + ) + } +} + +func TestRegisterNativeExecutionFFISigningPrimitive_RegistersExecutor(t *testing.T) { + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := RegisterNativeExecutionFFISigningPrimitive( + &mockNativeExecutionFFISigningPrimitive{ + signature: &frost.Signature{}, + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + executor := currentNativeExecutionFFIExecutor() + if executor == nil { + t.Fatal("expected native FFI executor registration") + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration.go b/pkg/frost/signing/native_ffi_primitive_registration.go new file mode 100644 index 0000000000..a62a38b19f --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration.go @@ -0,0 +1,107 @@ +package signing + +import ( + "fmt" + "sync" + + "github.com/ipfs/go-log/v2" +) + +var ( + registrationLogger = log.Logger("keep-frost-signing-registration") + protocolLogger = log.Logger("keep-frost-signing-protocol") + registrationErrorMu sync.RWMutex + lastRegistrationError error +) + +func setLastRegistrationError(err error) { + registrationErrorMu.Lock() + defer registrationErrorMu.Unlock() + lastRegistrationError = err +} + +// LastNativeRegistrationError returns the most recent error observed while +// registering build-tagged native FROST execution adapters or FFI signing +// primitives. It is nil when the most recent registration attempt succeeded +// or when no registration has been attempted yet. Callers that want to fail +// startup on a registration error should check this after invoking +// `RegisterNativeExecutionAdapterForBuild` rather than relying on the +// previously panicking registration helpers themselves. +func LastNativeRegistrationError() error { + registrationErrorMu.RLock() + defer registrationErrorMu.RUnlock() + return lastRegistrationError +} + +// NativeExecutionFFISigningPrimitiveProviderForBuild produces a native FFI +// signing primitive for the current build/runtime flavor. +type NativeExecutionFFISigningPrimitiveProviderForBuild func() ( + NativeExecutionFFISigningPrimitive, + error, +) + +// RegisterNativeExecutionFFISigningPrimitiveProviderForBuild registers +// build-scoped primitive provider used by +// RegisterNativeExecutionFFISigningPrimitiveForBuild. +func RegisterNativeExecutionFFISigningPrimitiveProviderForBuild( + provider NativeExecutionFFISigningPrimitiveProviderForBuild, +) error { + if provider == nil { + return fmt.Errorf("native execution FFI signing primitive provider is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFISigningPrimitiveProviderForBuild = provider + + return nil +} + +// UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild clears +// build-scoped primitive provider registration. +func UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeExecutionFFISigningPrimitiveProviderForBuild = nil +} + +func currentNativeExecutionFFISigningPrimitiveProviderForBuild() NativeExecutionFFISigningPrimitiveProviderForBuild { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeExecutionFFISigningPrimitiveProviderForBuild +} + +// RegisterNativeExecutionFFISigningPrimitiveForBuild attempts to register +// build-flavor native FFI signing primitive bindings. +// +// On default builds, this is a no-op. +// On `frost_native` builds, this can be wired to a concrete primitive. +// +// Registration errors are surfaced via `LastNativeRegistrationError()` rather +// than panicking, so a transient FFI lookup failure at init time does not +// crash the binary. Downstream code in `pkg/frost/signing/backend.go` already +// handles the absence of a registered native adapter through +// `ErrNativeCryptographyUnavailable`, so the legacy execution backend remains +// the safe-by-default path even when this registration fails. +func RegisterNativeExecutionFFISigningPrimitiveForBuild() { + err := registerNativeExecutionFFISigningPrimitiveForBuild() + if err != nil { + registrationLogger.Warnf( + "failed to register build-tagged native FFI signing primitive: [%v]; "+ + "the native execution backend will report unavailable and callers "+ + "that selected the legacy or native-with-fallback backend will "+ + "continue using the legacy bridge", + err, + ) + setLastRegistrationError(fmt.Errorf( + "failed to register build-tagged native FFI signing primitive: [%w]", + err, + )) + return + } + + setLastRegistrationError(nil) +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_default.go b/pkg/frost/signing/native_ffi_primitive_registration_default.go new file mode 100644 index 0000000000..a68007ea45 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_default.go @@ -0,0 +1,7 @@ +//go:build !frost_native + +package signing + +func registerNativeExecutionFFISigningPrimitiveForBuild() error { + return nil +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_default_build_test.go b/pkg/frost/signing/native_ffi_primitive_registration_default_build_test.go new file mode 100644 index 0000000000..6b492f8877 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_default_build_test.go @@ -0,0 +1,20 @@ +//go:build !frost_native + +package signing + +import "testing" + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_DefaultBuildNoop( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + if currentNativeExecutionFFIExecutor() != nil { + t.Fatal("expected no FFI executor registration on default build") + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_frost_native.go b/pkg/frost/signing/native_ffi_primitive_registration_frost_native.go new file mode 100644 index 0000000000..d6d3b3b3c8 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_frost_native.go @@ -0,0 +1,23 @@ +//go:build frost_native + +package signing + +import "fmt" + +func registerNativeExecutionFFISigningPrimitiveForBuild() error { + provider := currentNativeExecutionFFISigningPrimitiveProviderForBuild() + if provider == nil { + provider = defaultNativeExecutionFFISigningPrimitiveProviderForBuild + } + + primitive, err := provider() + if err != nil { + return err + } + + if primitive == nil { + return fmt.Errorf("native execution FFI signing primitive is nil") + } + + return RegisterNativeExecutionFFISigningPrimitive(primitive) +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_frost_native_test.go b/pkg/frost/signing/native_ffi_primitive_registration_frost_native_test.go new file mode 100644 index 0000000000..16d9468b2e --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_frost_native_test.go @@ -0,0 +1,106 @@ +//go:build frost_native + +package signing + +import ( + "errors" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/frost" +) + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_UsesProvider( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + err := RegisterNativeExecutionFFISigningPrimitiveProviderForBuild( + func() (NativeExecutionFFISigningPrimitive, error) { + return &mockNativeExecutionFFISigningPrimitive{ + signature: &frost.Signature{}, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + if currentNativeExecutionFFIExecutor() == nil { + t.Fatal("expected FFI executor registration from build provider") + } +} + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_UsesDefaultProvider( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + if currentNativeExecutionFFIExecutor() == nil { + t.Fatal("expected FFI executor registration from default build provider") + } +} + +func TestRegisterNativeExecutionFFISigningPrimitiveForBuild_ProviderErrorIsRecordedNotPanicked( + t *testing.T, +) { + UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild() + UnregisterNativeExecutionFFIExecutor() + t.Cleanup(UnregisterNativeExecutionFFISigningPrimitiveProviderForBuild) + t.Cleanup(UnregisterNativeExecutionFFIExecutor) + t.Cleanup(func() { setLastRegistrationError(nil) }) + + expectedErr := errors.New("provider error") + + err := RegisterNativeExecutionFFISigningPrimitiveProviderForBuild( + func() (NativeExecutionFFISigningPrimitive, error) { + return nil, expectedErr + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + defer func() { + if recovered := recover(); recovered != nil { + t.Fatalf( + "registration must not panic; recovered: [%v]", + recovered, + ) + } + }() + + // Pre-condition: the registration error slot is clear before invoking the + // helper, so any non-nil error after the call is from this attempt. + setLastRegistrationError(nil) + + RegisterNativeExecutionFFISigningPrimitiveForBuild() + + registered := LastNativeRegistrationError() + if registered == nil { + t.Fatal("expected LastNativeRegistrationError to surface the provider error") + } + if !strings.Contains(registered.Error(), expectedErr.Error()) { + t.Fatalf( + "LastNativeRegistrationError missing expected substring\nexpected: [%s]\nactual: [%v]", + expectedErr.Error(), + registered, + ) + } + + if currentNativeExecutionFFIExecutor() != nil { + t.Fatal( + "FFI executor must not be registered when the provider returned an error", + ) + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_registration_test.go b/pkg/frost/signing/native_ffi_primitive_registration_test.go new file mode 100644 index 0000000000..6711b0b105 --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_registration_test.go @@ -0,0 +1,26 @@ +package signing + +import ( + "strings" + "testing" +) + +func TestRegisterNativeExecutionFFISigningPrimitiveProviderForBuild_Nil( + t *testing.T, +) { + err := RegisterNativeExecutionFFISigningPrimitiveProviderForBuild(nil) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains( + err.Error(), + "native execution FFI signing primitive provider is nil", + ) { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native execution FFI signing primitive provider is nil", + err, + ) + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go new file mode 100644 index 0000000000..07d09c778e --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -0,0 +1,1342 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" + legacySigning "github.com/keep-network/keep-core/pkg/tecdsa/signing" +) + +func defaultNativeExecutionFFISigningPrimitiveProviderForBuild() ( + NativeExecutionFFISigningPrimitive, + error, +) { + if err := registerBuildTaggedNativeFROSTSigningEngine(); err != nil { + return nil, err + } + + return &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{}, nil +} + +// buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive is a +// transitional primitive that executes native two-round FROST when +// `frost-uniffi-v2` signer material is provided, and preserves legacy bridge +// execution for `frost-uniffi-v1` payloads. `frost-tbtc-signer-v1` uses the +// coarse signing flow for bootstrap engine versions and falls back to legacy +// signing for unsupported or failed coarse-path executions. +type buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive struct{} + +const buildTaggedTBTCSignerVersionPrefix = "tbtc-signer/" +const buildTaggedTBTCSignerBootstrapVersionPrerelease = "bootstrap" +const buildTaggedTBTCSignerSyntheticContributionDomain = "tbtc-signer-bootstrap-contribution-v1" +const buildTaggedTBTCSignerMessageTypePrefix = "frost_signing/native_tbtc_signer/" +const buildTaggedTBTCSignerConsumedAttemptReplayErrorFragment = "already consumed for sign attempt" + +// buildTaggedTBTCSignerConsumedAttemptReplayErrorCode is the structured Rust +// `ErrorResponse.code` value emitted by tbtc-signer when an `attempt_id` is +// reused after consumption. Preferred over substring matching on the message +// because the code is contract-stable: see `EngineError::code()` in the +// `tbtc-signer` crate. +const buildTaggedTBTCSignerConsumedAttemptReplayErrorCode = "consumed_attempt_replay" + +// buildTaggedTBTCSignerLegacyValidationErrorCode is the structured code +// emitted by tbtc-signer builds that pre-date the dedicated replay variant. +// Those builds route the replay path through `EngineError::Validation`, so +// the code on the wire is `validation_error` and the substring check on the +// message is the only signal callers have. Once the rolling upgrade is past +// the minimum-supported signer version, this code can be retired. +const buildTaggedTBTCSignerLegacyValidationErrorCode = "validation_error" + +type nativeTBTCSignerVersionedEngine interface { + Version() (string, error) +} + +type buildTaggedTBTCSignerRoundContributionMessage struct { + SenderIDValue uint32 `json:"senderID"` + SessionIDValue string `json:"sessionID"` + ContributionIdentifier uint16 `json:"contributionIdentifier"` + ContributionData []byte `json:"contributionData"` + // AttemptContextHash -- see nativeFROSTRoundOneCommitmentMessage + // for the RFC-21 Phase 1 migration contract. + AttemptContextHash []byte `json:"attemptContextHash,omitempty"` +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SenderID() group.MemberIndex { + return group.MemberIndex(bttsrcm.SenderIDValue) +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SessionID() string { + return bttsrcm.SessionIDValue +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) Type() string { + return buildTaggedTBTCSignerMessageTypePrefix + "round_contribution" +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) Marshal() ([]byte, error) { + return json.Marshal(bttsrcm) +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, bttsrcm); err != nil { + return err + } + + if bttsrcm.SenderID() == 0 { + return fmt.Errorf("sender ID is zero") + } + + if bttsrcm.SessionID() == "" { + return fmt.Errorf("session ID is empty") + } + + if bttsrcm.ContributionIdentifier == 0 { + return fmt.Errorf("contribution identifier is zero") + } + + if len(bttsrcm.ContributionData) == 0 { + return fmt.Errorf("contribution data is empty") + } + + if err := validateAttemptContextHashField( + bttsrcm.AttemptContextHash, + ); err != nil { + return err + } + + return nil +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + bttsrcm.AttemptContextHash = attemptContextHashFieldFromArray(hash) +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return attemptContextHashFieldToArray(bttsrcm.AttemptContextHash) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) Sign( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Message == nil { + return nil, fmt.Errorf("request message is nil") + } + + if request.SignerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + switch request.SignerMaterial.Format { + case NativeSignerMaterialFormatFrostUniFFIV2: + nativeSignerMaterial, err := decodeNativeFROSTUniFFIV2SignerMaterial( + request.SignerMaterial, + ) + if err != nil { + return nil, err + } + + return executeNativeFROSTSigning( + ctx, + logger, + request, + currentNativeFROSTSigningEngine(), + nativeSignerMaterial, + ) + + case NativeSignerMaterialFormatFrostUniFFIV1: + return btlcnnefsp.signWithLegacyTECDSABridge(ctx, logger, request) + + case NativeSignerMaterialFormatFrostTBTCSignerV1: + return btlcnnefsp.signWithTBTCSignerCoarseEngine(ctx, logger, request) + + default: + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + request.SignerMaterial.Format, + ) + } +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) signWithTBTCSignerCoarseEngine( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(request.SignerMaterial) + if err != nil { + return nil, err + } + + // Scaffold persistence-vs-execution gate. The resolver in #3959 refuses to + // BUILD scaffold-era signer material without the env opt-in, but material + // persisted from a previous opted-in session can still drive this signing + // path on later runs after the operator has unset the flag. Refuse to + // enter the FFI scaffold path (which feeds placeholder participant + // pubkeys into RunDKG) when the payload is scaffold-era and the operator + // has not actively opted in for this process. The check is per-call (not + // cached) so flipping the env back unset recovers fail-closed behavior + // without a restart, matching the contract documented on + // AcceptScaffoldKeyGroupEnvVar. + if payload.KeyGroupSource == NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey && + !AcceptScaffoldKeyGroupEnabled() { + return nil, fmt.Errorf( + "%w: refusing to drive the tbtc-signer FFI signing path with "+ + "scaffold-era %q signer material; set %s=true to opt in for "+ + "local/CI use only, never in production", + ErrNativeCryptographyUnavailable, + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + AcceptScaffoldKeyGroupEnvVar, + ) + } + + legacyPrivateKeyShare, err := decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare(payload) + if err != nil { + return nil, err + } + + nativeEngine := currentNativeTBTCSignerEngine() + if nativeEngine == nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "native tbtc-signer engine is unavailable", + payload.KeyGroupSource, + ) + } + + includedMembersSet, includedMembersIndexes, err := includedMembersFromRequest(request) + if err != nil { + if errors.Is(err, ErrInvalidSigningAttemptPolicy) { + return nil, fmt.Errorf( + "%w: invalid tbtc-signer signing attempt policy: %w", + ErrNativeBridgeOperationFailed, + err, + ) + } + + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("cannot determine included members: [%v]", err), + payload.KeyGroupSource, + ) + } + + dkgParticipants, dkgThreshold, err := buildTaggedTBTCSignerRunDKGInputsForIncludedMembers( + request, + includedMembersIndexes, + ) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("cannot prepare tbtc-signer RunDKG request: [%v]", err), + payload.KeyGroupSource, + ) + } + + dkgResult, err := nativeEngine.RunDKG( + request.SessionID, + dkgParticipants, + dkgThreshold, + ) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("tbtc-signer RunDKG failed: [%v]", err), + payload.KeyGroupSource, + ) + } + + if dkgResult == nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "tbtc-signer RunDKG returned nil result", + payload.KeyGroupSource, + ) + } + + if dkgResult.KeyGroup == "" { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "tbtc-signer RunDKG returned empty key group", + payload.KeyGroupSource, + ) + } + + keyGroupForRound, keyGroupSubstituted, err := buildTaggedTBTCSignerRoundKeyGroup( + payload, + dkgResult, + ) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + err.Error(), + payload.KeyGroupSource, + ) + } + + if keyGroupSubstituted && logger != nil { + logger.Debugf( + "substituting scaffold key group from payload source [%s]: payload [%s] -> RunDKG [%s]", + payload.KeyGroupSource, + payload.KeyGroup, + dkgResult.KeyGroup, + ) + } + + versionedEngine, isVersioned := nativeEngine.(nativeTBTCSignerVersionedEngine) + if !isVersioned { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + "tbtc-signer version API is unavailable; coarse round scaffold skipped", + payload.KeyGroupSource, + ) + } + + engineVersion, err := versionedEngine.Version() + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf( + "cannot query tbtc-signer version; coarse round scaffold skipped: [%v]", + err, + ), + payload.KeyGroupSource, + ) + } + + if !isBuildTaggedTBTCSignerBootstrapVersion(engineVersion) { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf( + "tbtc-signer version [%s] is not bootstrap; coarse round scaffold skipped", + engineVersion, + ), + payload.KeyGroupSource, + ) + } + + coarseSignatureBytes, err := executeBuildTaggedTBTCSignerBootstrapCoarseRoundWithSignature( + ctx, + request, + keyGroupForRound, + nativeEngine, + includedMembersSet, + includedMembersIndexes, + ) + if err != nil { + if isBuildTaggedTBTCSignerConsumedAttemptReplayError(err) { + return nil, fmt.Errorf( + "%w: consumed tbtc-signer attempt replay: %w: %v", + ErrNativeBridgeOperationFailed, + ErrConsumedSigningAttemptReplay, + err, + ) + } + + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("tbtc-signer bootstrap coarse round failed: [%v]", err), + payload.KeyGroupSource, + ) + } + + coarseSignature, err := decodeBuildTaggedTBTCSignerSignature(coarseSignatureBytes) + if err != nil { + return btlcnnefsp.fallbackTBTCSignerLegacySigning( + ctx, + logger, + request, + legacyPrivateKeyShare, + fmt.Sprintf("cannot decode tbtc-signer coarse signature: [%v]", err), + payload.KeyGroupSource, + ) + } + + if logger != nil { + logger.Debugf( + "validated tbtc-signer key-group contract via RunDKG and bootstrap coarse round; returning coarse signature", + ) + } + + emitNativeTBTCSignerCoarseSignatureEvent( + NativeTBTCSignerCoarseSignatureEvent{ + SessionID: request.SessionID, + KeyGroupSource: payload.KeyGroupSource, + EngineVersion: engineVersion, + }, + ) + + return coarseSignature, nil +} + +func isBuildTaggedTBTCSignerConsumedAttemptReplayError(err error) bool { + if err == nil { + return false + } + + // Prefer the structured `code` field from the FFI error envelope when it + // is reachable through the error chain. The Rust signer's + // `EngineError::code()` value `"consumed_attempt_replay"` is a + // contract-stable identifier; this check survives any cosmetic rewording + // of the human-readable message on either side. + // + // Older signer builds emit `validation_error` for the replay path with + // the legacy wording in the message. For those, fall through to the + // substring check restricted to the structured message field so a + // `validation_error` carrying an unrelated error chain string cannot be + // mistaken for a replay. Any other recognized code is authoritative. + var structured *buildTaggedTBTCSignerStructuredError + if errors.As(err, &structured) && structured.Code != "" { + switch structured.Code { + case buildTaggedTBTCSignerConsumedAttemptReplayErrorCode: + return true + case buildTaggedTBTCSignerLegacyValidationErrorCode: + return messageMatchesLegacyConsumedAttemptReplay(structured.Message) + default: + return false + } + } + + // No structured code reachable — the error chain pre-dates the FFI + // envelope. The legacy wording is preserved by the current tbtc-signer + // release so this branch continues to work during the rolling upgrade + // window. Match on the whole rendered string for maximum compatibility. + return messageMatchesLegacyConsumedAttemptReplay(err.Error()) +} + +func messageMatchesLegacyConsumedAttemptReplay(message string) bool { + lower := strings.ToLower(message) + return strings.Contains(lower, "attempt_id") && + strings.Contains(lower, buildTaggedTBTCSignerConsumedAttemptReplayErrorFragment) +} + +func buildTaggedTBTCSignerRunDKGInputs( + request *NativeExecutionFFISigningRequest, +) ([]NativeTBTCSignerDKGParticipant, uint16, error) { + _, includedMembersIndexes, err := includedMembersFromRequest(request) + if err != nil { + return nil, 0, err + } + + return buildTaggedTBTCSignerRunDKGInputsForIncludedMembers( + request, + includedMembersIndexes, + ) +} + +func buildTaggedTBTCSignerRunDKGInputsForIncludedMembers( + request *NativeExecutionFFISigningRequest, + includedMembersIndexes []group.MemberIndex, +) ([]NativeTBTCSignerDKGParticipant, uint16, error) { + if request == nil { + return nil, 0, fmt.Errorf("request is nil") + } + + if len(includedMembersIndexes) < 2 { + return nil, 0, fmt.Errorf("insufficient included members for DKG") + } + + threshold := request.DishonestThreshold + 1 + if threshold < 2 { + return nil, 0, fmt.Errorf("derived threshold is below minimum: [%v]", threshold) + } + + if threshold > len(includedMembersIndexes) { + return nil, 0, fmt.Errorf( + "derived threshold exceeds included members count: [%v] > [%v]", + threshold, + len(includedMembersIndexes), + ) + } + + participants := make([]NativeTBTCSignerDKGParticipant, 0, len(includedMembersIndexes)) + for _, memberIndex := range includedMembersIndexes { + if memberIndex == 0 { + return nil, 0, fmt.Errorf("included member index is zero") + } + + identifier := uint16(memberIndex) + participants = append( + participants, + NativeTBTCSignerDKGParticipant{ + Identifier: identifier, + PublicKeyHex: buildTaggedTBTCSignerDKGPlaceholderPublicKeyHex(identifier), + }, + ) + } + + return participants, uint16(threshold), nil +} + +func buildTaggedTBTCSignerDKGPlaceholderPublicKeyHex(identifier uint16) string { + // Transitional placeholder until canonical member public keys are available + // in the native signing request path. + return fmt.Sprintf("02%04x", identifier) +} + +func buildTaggedTBTCSignerRoundKeyGroup( + payload *NativeTBTCSignerMaterialPayload, + dkgResult *NativeTBTCSignerDKGResult, +) (string, bool, error) { + if payload == nil { + return "", false, fmt.Errorf("tbtc-signer payload is nil") + } + + if dkgResult == nil { + return "", false, fmt.Errorf("tbtc-signer RunDKG result is nil") + } + + if dkgResult.KeyGroup == "" { + return "", false, fmt.Errorf("tbtc-signer RunDKG key group is empty") + } + + if payload.KeyGroup == dkgResult.KeyGroup { + return payload.KeyGroup, false, nil + } + + if payload.KeyGroupSource == NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey { + // Scaffold compatibility: legacy-wallet-pubkey key groups are + // placeholder-only and expected to diverge from coarse RunDKG output. + // Refuse the substitution by default so a production deployment that + // somehow ended up with placeholder material does not silently route + // signing through whatever key group the Rust side happens to return. + // The operator must explicitly opt into the scaffold path via + // AcceptScaffoldKeyGroupEnvVar; the env-var check is per-call (not + // cached) so flipping it off recovers fail-closed behavior without a + // restart. + if !AcceptScaffoldKeyGroupEnabled() { + return "", false, fmt.Errorf( + "tbtc-signer key group source %q is scaffold-era placeholder "+ + "material and may not be silently substituted with the "+ + "RunDKG output; set %s=true to opt in for local/CI use "+ + "only, never in production", + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + AcceptScaffoldKeyGroupEnvVar, + ) + } + return dkgResult.KeyGroup, true, nil + } + + return "", false, fmt.Errorf("tbtc-signer key group does not match RunDKG result") +} + +func isBuildTaggedTBTCSignerBootstrapVersion(version string) bool { + version = strings.TrimSpace(version) + if !strings.HasPrefix(version, buildTaggedTBTCSignerVersionPrefix) { + return false + } + + version = strings.TrimPrefix(version, buildTaggedTBTCSignerVersionPrefix) + coreVersion, prerelease, hasPrerelease := strings.Cut(version, "-") + if !hasPrerelease { + return false + } + + if prerelease != buildTaggedTBTCSignerBootstrapVersionPrerelease && + !strings.HasPrefix( + prerelease, + buildTaggedTBTCSignerBootstrapVersionPrerelease+".", + ) { + return false + } + + coreSegments := strings.Split(coreVersion, ".") + if len(coreSegments) != 3 { + return false + } + + // Bootstrap scaffold must be enabled only on 0.x.y pre-release builds. + if coreSegments[0] != "0" { + return false + } + + for _, segment := range coreSegments { + if segment == "" { + return false + } + + for _, character := range segment { + if character < '0' || character > '9' { + return false + } + } + } + + return true +} + +func executeBuildTaggedTBTCSignerBootstrapCoarseRound( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + keyGroup string, + nativeEngine NativeTBTCSignerEngine, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, +) error { + _, err := executeBuildTaggedTBTCSignerBootstrapCoarseRoundWithSignature( + ctx, + request, + keyGroup, + nativeEngine, + includedMembersSet, + includedMembersIndexes, + ) + + return err +} + +func executeBuildTaggedTBTCSignerBootstrapCoarseRoundWithSignature( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + keyGroup string, + nativeEngine NativeTBTCSignerEngine, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, +) ([]byte, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Message == nil { + return nil, fmt.Errorf("request message is nil") + } + + if nativeEngine == nil { + return nil, fmt.Errorf("native tbtc-signer engine is nil") + } + + if ctx == nil { + ctx = context.Background() + } + + if includedMembersSet == nil || len(includedMembersIndexes) == 0 { + var err error + includedMembersSet, includedMembersIndexes, err = includedMembersFromRequest(request) + if err != nil { + return nil, fmt.Errorf("cannot determine included members: [%w]", err) + } + } + + if _, ok := includedMembersSet[request.MemberIndex]; !ok { + return nil, fmt.Errorf( + "member [%v] not included in tbtc-signer signing attempt", + request.MemberIndex, + ) + } + + messageBytes := request.Message.Bytes() + if len(messageBytes) == 0 { + messageBytes = []byte{0} + } + + if request.MemberIndex == 0 { + return nil, fmt.Errorf("request member index is zero") + } + + signingParticipants, err := buildTaggedTBTCSignerSigningParticipants( + includedMembersIndexes, + ) + if err != nil { + return nil, fmt.Errorf("cannot derive signing participants: [%w]", err) + } + + roundState, err := nativeEngine.StartSignRound( + request.SessionID, + uint16(request.MemberIndex), + messageBytes, + keyGroup, + signingParticipants, + ) + if err != nil { + return nil, fmt.Errorf("start sign round failed: [%w]", err) + } + + if roundState == nil { + return nil, fmt.Errorf("start sign round returned nil state") + } + + if roundState.RequiredContributions == 0 { + return nil, fmt.Errorf("start sign round required contributions are zero") + } + + if len(signingParticipants) > 0 { + if len(roundState.SigningParticipants) != len(signingParticipants) { + return nil, fmt.Errorf( + "start sign round returned unexpected signing participants count: [%v] != [%v]", + len(roundState.SigningParticipants), + len(signingParticipants), + ) + } + + for i := range signingParticipants { + if roundState.SigningParticipants[i] != signingParticipants[i] { + return nil, fmt.Errorf( + "start sign round returned unexpected signing participant at index [%d]: [%v] != [%v]", + i, + roundState.SigningParticipants[i], + signingParticipants[i], + ) + } + } + } + + roundContributions, err := buildTaggedTBTCSignerRoundContributions( + ctx, + request, + roundState, + includedMembersSet, + includedMembersIndexes, + ) + if err != nil { + return nil, fmt.Errorf("cannot collect round contributions: [%w]", err) + } + + if len(roundContributions) < int(roundState.RequiredContributions) { + return nil, fmt.Errorf( + "insufficient round contributions: [%v] < [%v]", + len(roundContributions), + roundState.RequiredContributions, + ) + } + + signature, err := nativeEngine.FinalizeSignRound( + request.SessionID, + roundContributions, + ) + if err != nil { + return nil, fmt.Errorf("finalize sign round failed: [%w]", err) + } + + if len(signature) == 0 { + return nil, fmt.Errorf("finalize sign round returned empty signature") + } + + return signature, nil +} + +func decodeBuildTaggedTBTCSignerSignature(signature []byte) (*frost.Signature, error) { + if len(signature) == 0 { + return nil, fmt.Errorf("signature is empty") + } + + // Unmarshal validates signature wire format (length + split into R/S) only. + // Cryptographic validity is enforced by downstream Schnorr verification at + // submission time. + result := &frost.Signature{} + if err := result.Unmarshal(signature); err != nil { + return nil, fmt.Errorf("invalid frost signature bytes: [%w]", err) + } + + return result, nil +} + +func buildTaggedTBTCSignerSigningParticipants( + includedMembersIndexes []group.MemberIndex, +) ([]uint16, error) { + if len(includedMembersIndexes) == 0 { + return nil, fmt.Errorf("included members are empty") + } + + signingParticipants := make([]uint16, 0, len(includedMembersIndexes)) + seenParticipants := make(map[uint16]struct{}, len(includedMembersIndexes)) + + for _, memberIndex := range includedMembersIndexes { + if memberIndex == 0 { + return nil, fmt.Errorf("included member index is zero") + } + + participant := uint16(memberIndex) + if _, ok := seenParticipants[participant]; ok { + return nil, fmt.Errorf("duplicate included member index: [%v]", memberIndex) + } + + seenParticipants[participant] = struct{}{} + signingParticipants = append(signingParticipants, participant) + } + + return signingParticipants, nil +} + +func buildTaggedTBTCSignerRoundContributions( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + roundState *NativeTBTCSignerRoundState, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, +) ([]NativeTBTCSignerRoundContribution, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Channel == nil { + // Compatibility path for unit tests that do not attach a broadcast + // channel. Runtime signer flows provide a channel and use contribution + // exchange with peers. + return buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + includedMembersIndexes, + ) + } + + ownContribution, err := buildTaggedTBTCSignerOwnRoundContribution( + request, + roundState, + ) + if err != nil { + return nil, fmt.Errorf("cannot build own round contribution: [%w]", err) + } + + roundContributionMessage := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: uint32(request.MemberIndex), + SessionIDValue: request.SessionID, + ContributionIdentifier: ownContribution.Identifier, + ContributionData: append([]byte{}, ownContribution.Data...), + } + + if err := request.Channel.Send( + ctx, + roundContributionMessage, + net.BackoffRetransmissionStrategy, + ); err != nil { + return nil, fmt.Errorf("cannot send round contribution message: [%w]", err) + } + + // RFC-21 Phase 4.2/4.3: recorder comes from the roast-retry + // registry; deferred submission pushes the snapshot into + // Coordinator.RecordEvidence at end-of-collect. NoOp fallback + // when nothing is registered preserves Phase 2 receive + // semantics. + contributionsRecorder := roastRetryRecorderForCollect() + defer submitSnapshotIfActive(request.SessionID, contributionsRecorder) + peerMessages, err := collectBuildTaggedTBTCSignerRoundContributionMessages( + ctx, + request, + includedMembersSet, + includedMembersIndexes, + contributionsRecorder, + ) + if err != nil { + return nil, err + } + + contributionsBySender := map[group.MemberIndex]NativeTBTCSignerRoundContribution{ + request.MemberIndex: ownContribution, + } + + for senderID, message := range peerMessages { + contributionsBySender[senderID] = NativeTBTCSignerRoundContribution{ + Identifier: message.ContributionIdentifier, + Data: append([]byte{}, message.ContributionData...), + } + } + + orderedContributions := make( + []NativeTBTCSignerRoundContribution, + 0, + len(includedMembersIndexes), + ) + for _, memberIndex := range includedMembersIndexes { + contribution, ok := contributionsBySender[memberIndex] + if !ok { + return nil, fmt.Errorf("missing contribution from member [%v]", memberIndex) + } + + orderedContributions = append(orderedContributions, contribution) + } + + return orderedContributions, nil +} + +func buildTaggedTBTCSignerOwnRoundContribution( + request *NativeExecutionFFISigningRequest, + roundState *NativeTBTCSignerRoundState, +) (NativeTBTCSignerRoundContribution, error) { + if request == nil { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf("request is nil") + } + + if request.MemberIndex == 0 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf("request member index is zero") + } + + if roundState != nil && roundState.OwnContribution != nil { + ownContribution := roundState.OwnContribution + if ownContribution.Identifier == 0 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "round state own contribution identifier is zero", + ) + } + + if len(ownContribution.Data) == 0 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "round state own contribution data is empty", + ) + } + + if ownContribution.Identifier != uint16(request.MemberIndex) { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "round state own contribution identifier [%v] does not match member index [%v]", + ownContribution.Identifier, + request.MemberIndex, + ) + } + + return NativeTBTCSignerRoundContribution{ + Identifier: ownContribution.Identifier, + Data: append([]byte{}, ownContribution.Data...), + }, nil + } + + ownContributions, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + []group.MemberIndex{request.MemberIndex}, + ) + if err != nil { + return NativeTBTCSignerRoundContribution{}, err + } + + if len(ownContributions) != 1 { + return NativeTBTCSignerRoundContribution{}, fmt.Errorf( + "unexpected own contribution count: [%v]", + len(ownContributions), + ) + } + + return ownContributions[0], nil +} + +func collectBuildTaggedTBTCSignerRoundContributionMessages( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, + evidence attempt.EvidenceRecorder, +) (map[group.MemberIndex]*buildTaggedTBTCSignerRoundContributionMessage, error) { + expectedMessagesCount := len(includedMembersIndexes) - 1 + if expectedMessagesCount <= 0 { + return map[group.MemberIndex]*buildTaggedTBTCSignerRoundContributionMessage{}, nil + } + + recvCtx, cancelRecvCtx := context.WithCancel(ctx) + defer cancelRecvCtx() + + messageChan := make( + chan *buildTaggedTBTCSignerRoundContributionMessage, + expectedMessagesCount*4+1, + ) + + request.Channel.Recv(recvCtx, func(message net.Message) { + payload, ok := message.Payload().(*buildTaggedTBTCSignerRoundContributionMessage) + if !ok { + return + } + + if !shouldAcceptNativeFROSTMessage( + request, + includedMembersSet, + payload.SenderID(), + payload.SessionID(), + message.SenderPublicKey(), + ) { + evidence.RecordReject(payload.SenderID(), "validation_gate_rejected") + return + } + + if err := verifyMessageAttemptContextHash(payload, request.SessionID); err != nil { + evidence.RecordReject(payload.SenderID(), "attempt_context_hash_mismatch") + return + } + + _ = enqueueOrRecordOverflow(payload, messageChan, evidence) + }) + + receivedMessages := make( + map[group.MemberIndex]*buildTaggedTBTCSignerRoundContributionMessage, + ) + for len(receivedMessages) < expectedMessagesCount { + select { + case <-ctx.Done(): + return nil, fmt.Errorf( + "tbtc-signer round contribution collection interrupted: [%w]", + ctx.Err(), + ) + + case message := <-messageChan: + // First-write-wins / equal-or-reject. A peer that retransmits the + // same contribution is idempotent; a peer that mutates its own + // contribution after the first send is a ROAST evidence concern + // and must not be allowed to overwrite the persisted view. + senderID := message.SenderID() + if existing, ok := receivedMessages[senderID]; ok { + if !buildTaggedTBTCSignerRoundContributionMessagesEqual( + existing, + message, + ) { + evidence.RecordConflict(senderID) + protocolLogger.Warnf( + "dropping conflicting tbtc-signer round contribution "+ + "from sender [%d]; first-write-wins keeps the "+ + "originally accepted contribution", + senderID, + ) + } + continue + } + receivedMessages[senderID] = message + } + } + + return receivedMessages, nil +} + +func buildTaggedTBTCSignerRoundContributionMessagesEqual( + left, right *buildTaggedTBTCSignerRoundContributionMessage, +) bool { + if left == nil || right == nil { + return left == right + } + return left.SenderIDValue == right.SenderIDValue && + left.SessionIDValue == right.SessionIDValue && + left.ContributionIdentifier == right.ContributionIdentifier && + bytes.Equal(left.ContributionData, right.ContributionData) && + bytes.Equal(left.AttemptContextHash, right.AttemptContextHash) +} + +func buildTaggedTBTCSignerSyntheticRoundContributions( + roundState *NativeTBTCSignerRoundState, + includedMembersIndexes []group.MemberIndex, +) ([]NativeTBTCSignerRoundContribution, error) { + if roundState == nil { + return nil, fmt.Errorf("round state is nil") + } + + if roundState.SessionID == "" { + return nil, fmt.Errorf("round state session ID is empty") + } + + if roundState.RoundID == "" { + return nil, fmt.Errorf("round state round ID is empty") + } + + if roundState.MessageDigestHex == "" { + return nil, fmt.Errorf("round state message digest is empty") + } + + contributions := make( + []NativeTBTCSignerRoundContribution, + 0, + len(includedMembersIndexes), + ) + + for _, memberIndex := range includedMembersIndexes { + if memberIndex == 0 { + return nil, fmt.Errorf("included member index is zero") + } + + identifier := uint16(memberIndex) + seed := fmt.Sprintf( + "%s:%s:%s:%s:%d", + buildTaggedTBTCSignerSyntheticContributionDomain, + roundState.SessionID, + roundState.RoundID, + roundState.MessageDigestHex, + identifier, + ) + shareDigest := sha256.Sum256([]byte(seed)) + + contributions = append( + contributions, + NativeTBTCSignerRoundContribution{ + Identifier: identifier, + Data: append([]byte{}, shareDigest[:]...), + }, + ) + } + + return contributions, nil +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) signWithLegacyTECDSABridge( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + privateKeyShare, err := decodeBuildTaggedLegacyPrivateKeyShare( + request.SignerMaterial, + ) + if err != nil { + return nil, err + } + + return btlcnnefsp.signWithLegacyPrivateKeyShare( + ctx, + logger, + request, + privateKeyShare, + ) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) signWithLegacyPrivateKeyShare( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + privateKeyShare *tecdsa.PrivateKeyShare, +) (*frost.Signature, error) { + if privateKeyShare == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + excludedMembersIndexes := []group.MemberIndex{} + if request.Attempt != nil { + excludedMembersIndexes = request.Attempt.ExcludedMembersIndexes + } + + legacyResult, err := legacySigning.Execute( + ctx, + logger, + request.Message, + request.SessionID, + request.MemberIndex, + privateKeyShare, + request.GroupSize, + request.DishonestThreshold, + excludedMembersIndexes, + request.Channel, + request.MembershipValidator, + ) + if err != nil { + return nil, err + } + + return FromTECDSASignature(legacyResult.Signature) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) fallbackTBTCSignerLegacySigning( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + legacyPrivateKeyShare *tecdsa.PrivateKeyShare, + reason string, + keyGroupSource string, +) (*frost.Signature, error) { + emitNativeTBTCSignerFallbackEvent( + NativeTBTCSignerFallbackEvent{ + SessionID: request.SessionID, + Reason: reason, + KeyGroupSource: keyGroupSource, + LegacyPrivateKeyShareExists: legacyPrivateKeyShare != nil, + }, + ) + + if legacyPrivateKeyShare == nil { + return nil, fmt.Errorf("%w: %s", ErrNativeCryptographyUnavailable, reason) + } + + if logger != nil { + logger.Warnf( + "falling back to legacy tECDSA signer path for tbtc-signer payload: [%s]", + reason, + ) + } + + return btlcnnefsp.signWithLegacyPrivateKeyShare( + ctx, + logger, + request, + legacyPrivateKeyShare, + ) +} + +func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { + registerBuildTaggedTBTCSignerUnmarshallers(channel) + registerNativeFROSTSigningUnmarshallers(channel) + legacySigning.RegisterUnmarshallers(channel) +} + +func registerBuildTaggedTBTCSignerUnmarshallers(channel net.BroadcastChannel) { + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &buildTaggedTBTCSignerRoundContributionMessage{} + }) +} + +func decodeBuildTaggedLegacyPrivateKeyShare( + signerMaterial *NativeSignerMaterial, +) (*tecdsa.PrivateKeyShare, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + if signerMaterial.Format != NativeSignerMaterialFormatFrostUniFFIV1 { + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + signerMaterial.Format, + ) + } + + if len(signerMaterial.Payload) == 0 { + return nil, fmt.Errorf( + "%w: signer material payload is empty", + ErrNativeCryptographyUnavailable, + ) + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(signerMaterial.Payload); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal signer material payload: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + return privateKeyShare, nil +} + +func decodeBuildTaggedTBTCSignerMaterialPayload( + signerMaterial *NativeSignerMaterial, +) (*NativeTBTCSignerMaterialPayload, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + if signerMaterial.Format != NativeSignerMaterialFormatFrostTBTCSignerV1 { + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + signerMaterial.Format, + ) + } + + if len(signerMaterial.Payload) == 0 { + return nil, fmt.Errorf( + "%w: signer material payload is empty", + ErrNativeCryptographyUnavailable, + ) + } + + var payload NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(signerMaterial.Payload, &payload); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal tbtc-signer payload: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if payload.KeyGroup == "" { + return nil, fmt.Errorf( + "%w: tbtc-signer key group is empty", + ErrNativeCryptographyUnavailable, + ) + } + + return &payload, nil +} + +func decodeBuildTaggedTBTCSignerKeyGroup( + signerMaterial *NativeSignerMaterial, +) (string, error) { + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return "", err + } + + return payload.KeyGroup, nil +} + +func decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare( + payload *NativeTBTCSignerMaterialPayload, +) (*tecdsa.PrivateKeyShare, error) { + if payload == nil || payload.LegacyPrivateKeyShareHex == "" { + return nil, nil + } + + legacyPrivateKeySharePayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + return nil, fmt.Errorf( + "%w: cannot decode tbtc-signer legacy private key share: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(legacyPrivateKeySharePayload); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal tbtc-signer legacy private key share: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + return privateKeyShare, nil +} diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go new file mode 100644 index 0000000000..5f00b47cca --- /dev/null +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go @@ -0,0 +1,2960 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/internal/tecdsatest" + "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +type mockBuildTaggedTBTCSignerEngine struct { + runDKGCalled bool + runDKGSessionID string + runDKGParticipants []NativeTBTCSignerDKGParticipant + runDKGThreshold uint16 + runDKGResult *NativeTBTCSignerDKGResult + runDKGErr error + runDKGFn func( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) + version string + versionErr error + startCalled bool + startSessionID string + startMemberID uint16 + startMessage []byte + startKeyGroup string + startSigningParticipants []uint16 + startSignRoundFn func( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + ) (*NativeTBTCSignerRoundState, error) + startRoundState *NativeTBTCSignerRoundState + startErr error + finalizeCalled bool + finalizeSessionID string + finalizeInputs []NativeTBTCSignerRoundContribution + finalizeSignature []byte + finalizeErr error +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + mbttse.runDKGCalled = true + mbttse.runDKGSessionID = sessionID + mbttse.runDKGParticipants = append( + []NativeTBTCSignerDKGParticipant{}, + participants..., + ) + mbttse.runDKGThreshold = threshold + + if mbttse.runDKGErr != nil { + return nil, mbttse.runDKGErr + } + + if mbttse.runDKGFn != nil { + return mbttse.runDKGFn(sessionID, participants, threshold) + } + + if mbttse.runDKGResult != nil { + return mbttse.runDKGResult, nil + } + + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) Version() (string, error) { + if mbttse.versionErr != nil { + return "", mbttse.versionErr + } + + return mbttse.version, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, +) (*NativeTBTCSignerRoundState, error) { + mbttse.startCalled = true + mbttse.startSessionID = sessionID + mbttse.startMemberID = memberIdentifier + mbttse.startMessage = append([]byte{}, message...) + mbttse.startKeyGroup = keyGroup + mbttse.startSigningParticipants = append([]uint16{}, signingParticipants...) + + if mbttse.startErr != nil { + return nil, mbttse.startErr + } + + if mbttse.startSignRoundFn != nil { + return mbttse.startSignRoundFn( + sessionID, + memberIdentifier, + message, + keyGroup, + signingParticipants, + ) + } + + if mbttse.startRoundState != nil { + if len(mbttse.startRoundState.SigningParticipants) == 0 { + mbttse.startRoundState.SigningParticipants = append( + []uint16{}, + signingParticipants..., + ) + } + + return mbttse.startRoundState, nil + } + + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "00", + SigningParticipants: append([]uint16{}, signingParticipants...), + }, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, +) ([]byte, error) { + mbttse.finalizeCalled = true + mbttse.finalizeSessionID = sessionID + mbttse.finalizeInputs = append( + []NativeTBTCSignerRoundContribution{}, + roundContributions..., + ) + + if mbttse.finalizeErr != nil { + return nil, mbttse.finalizeErr + } + + if len(mbttse.finalizeSignature) > 0 { + return append([]byte{}, mbttse.finalizeSignature...), nil + } + + return []byte{0xaa}, nil +} + +func (mbttse *mockBuildTaggedTBTCSignerEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + return nil, errors.New("not implemented") +} + +type deterministicBuildTaggedTBTCSignerBootstrapRoundEngine struct { + roundState *NativeTBTCSignerRoundState + finalizeMutex sync.Mutex + finalizeCalls int + finalizeInput []NativeTBTCSignerRoundContribution +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + _ []byte, + _ string, + signingParticipants []uint16, +) (*NativeTBTCSignerRoundState, error) { + if dbttsbre.roundState != nil { + if dbttsbre.roundState.OwnContribution == nil { + dbttsbre.roundState.OwnContribution = &NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{byte(memberIdentifier), 0xab}, + } + } + + if len(dbttsbre.roundState.SigningParticipants) == 0 { + dbttsbre.roundState.SigningParticipants = append( + []uint16{}, + signingParticipants..., + ) + } + + return dbttsbre.roundState, nil + } + + if len(signingParticipants) == 0 { + signingParticipants = []uint16{memberIdentifier} + } + + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "00", + SigningParticipants: append([]uint16{}, signingParticipants...), + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{byte(memberIdentifier), 0xab}, + }, + }, nil +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) FinalizeSignRound( + _ string, + roundContributions []NativeTBTCSignerRoundContribution, +) ([]byte, error) { + dbttsbre.finalizeMutex.Lock() + defer dbttsbre.finalizeMutex.Unlock() + + dbttsbre.finalizeCalls++ + dbttsbre.finalizeInput = append( + []NativeTBTCSignerRoundContribution{}, + roundContributions..., + ) + + return []byte{0xaa}, nil +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + return nil, errors.New("not implemented") +} + +func (dbttsbre *deterministicBuildTaggedTBTCSignerBootstrapRoundEngine) finalizeInputs() []NativeTBTCSignerRoundContribution { + dbttsbre.finalizeMutex.Lock() + defer dbttsbre.finalizeMutex.Unlock() + + return append([]NativeTBTCSignerRoundContribution{}, dbttsbre.finalizeInput...) +} + +func buildTaggedTBTCSignerValidTestSignature(seed byte) []byte { + signature := make([]byte, 64) + for i := range signature { + signature[i] = seed + byte(i) + } + + return signature +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_ValidatesRequest( + t *testing.T, +) { + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err := primitive.Sign(nil, nil, nil) + if err == nil { + t.Fatal("expected error") + } + + if err.Error() != "request is nil" { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_ValidatesMessage( + t *testing.T, +) { + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x01}, + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if err.Error() != "request message is nil" { + t.Fatalf( + "unexpected error\nexpected: [%s]\nactual: [%v]", + "request message is nil", + err, + ) + } +} + +func TestDecodeBuildTaggedLegacyPrivateKeyShare(t *testing.T) { + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(5) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + expectedPrivateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + expectedPayload, err := expectedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + decodedPrivateKeyShare, err := decodeBuildTaggedLegacyPrivateKeyShare( + &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: expectedPayload, + }, + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(expectedPayload, actualPayload) { + t.Fatalf( + "unexpected decoded private key share\nexpected: [%x]\nactual: [%x]", + expectedPayload, + actualPayload, + ) + } +} + +func TestDecodeBuildTaggedLegacyPrivateKeyShare_RejectsInvalidMaterial( + t *testing.T, +) { + testCases := []struct { + name string + signerMaterial *NativeSignerMaterial + }{ + { + name: "nil signer material", + signerMaterial: nil, + }, + { + name: "unsupported format", + signerMaterial: &NativeSignerMaterial{ + Format: "other", + Payload: []byte{0x01}, + }, + }, + { + name: "empty payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + }, + }, + { + name: "invalid payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: big.NewInt(123).Bytes(), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := decodeBuildTaggedLegacyPrivateKeyShare(tc.signerMaterial) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerKeyGroup(t *testing.T) { + keyGroup, err := decodeBuildTaggedTBTCSignerKeyGroup(&NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1"}`), + }) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if keyGroup != "group-1" { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + "group-1", + keyGroup, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerKeyGroup_RejectsInvalidMaterial( + t *testing.T, +) { + testCases := []struct { + name string + signerMaterial *NativeSignerMaterial + }{ + { + name: "nil signer material", + signerMaterial: nil, + }, + { + name: "unsupported format", + signerMaterial: &NativeSignerMaterial{ + Format: "other", + Payload: []byte(`{"keyGroup":"group-1"}`), + }, + }, + { + name: "empty payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + }, + }, + { + name: "invalid payload", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":`), + }, + }, + { + name: "empty key group", + signerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":""}`), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := decodeBuildTaggedTBTCSignerKeyGroup(tc.signerMaterial) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerLegacyPrivateKeyShare(t *testing.T) { + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(5) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + expectedPrivateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + expectedPayload, err := expectedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + decodedPrivateKeyShare, err := decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare( + &NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + LegacyPrivateKeyShareHex: hex.EncodeToString(expectedPayload), + }, + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if decodedPrivateKeyShare == nil { + t.Fatal("expected decoded private key share") + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(expectedPayload, actualPayload) { + t.Fatalf( + "unexpected decoded private key share\nexpected: [%x]\nactual: [%x]", + expectedPayload, + actualPayload, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerLegacyPrivateKeyShare_RejectsInvalidPayload( + t *testing.T, +) { + testCases := []struct { + name string + payload *NativeTBTCSignerMaterialPayload + expectError bool + }{ + { + name: "nil payload", + payload: nil, + expectError: false, + }, + { + name: "empty legacy private key share", + payload: &NativeTBTCSignerMaterialPayload{}, + expectError: false, + }, + { + name: "invalid hex", + payload: &NativeTBTCSignerMaterialPayload{ + LegacyPrivateKeyShareHex: "zz", + }, + expectError: true, + }, + { + name: "invalid private key share payload", + payload: &NativeTBTCSignerMaterialPayload{ + LegacyPrivateKeyShareHex: hex.EncodeToString(big.NewInt(123).Bytes()), + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + decoded, err := decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare(tc.payload) + + if tc.expectError { + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + return + } + + if err != nil { + t.Fatalf("expected nil error, got: [%v]", err) + } + + if decoded != nil { + t.Fatalf("expected nil decoded private key share, got: [%v]", decoded) + } + }) + } +} + +func TestBuildTaggedTBTCSignerRunDKGInputs(t *testing.T) { + participants, threshold, err := buildTaggedTBTCSignerRunDKGInputs( + &NativeExecutionFFISigningRequest{ + GroupSize: 5, + DishonestThreshold: 2, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 3, 5}, + }, + }, + ) + if err != nil { + t.Fatalf("unexpected RunDKG inputs error: [%v]", err) + } + + if threshold != 3 { + t.Fatalf( + "unexpected threshold\nexpected: [%v]\nactual: [%v]", + 3, + threshold, + ) + } + + if len(participants) != 3 { + t.Fatalf( + "unexpected participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(participants), + ) + } + + expectedIdentifiers := []uint16{1, 3, 5} + expectedPublicKeys := []string{"020001", "020003", "020005"} + + for i := range participants { + if participants[i].Identifier != expectedIdentifiers[i] { + t.Fatalf( + "unexpected participant identifier at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedIdentifiers[i], + participants[i].Identifier, + ) + } + + if participants[i].PublicKeyHex != expectedPublicKeys[i] { + t.Fatalf( + "unexpected participant public key at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedPublicKeys[i], + participants[i].PublicKeyHex, + ) + } + } +} + +func TestBuildTaggedTBTCSignerRunDKGInputs_RejectsInvalidRequest(t *testing.T) { + testCases := []struct { + name string + request *NativeExecutionFFISigningRequest + }{ + { + name: "zero group size", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 0, + DishonestThreshold: 1, + }, + }, + { + name: "derived threshold exceeds participants", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 2, + DishonestThreshold: 2, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := buildTaggedTBTCSignerRunDKGInputs(tc.request) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestIncludedMembersFromRequest_RejectsInvalidAttemptPolicy(t *testing.T) { + testCases := []struct { + name string + request *NativeExecutionFFISigningRequest + errFragment string + }{ + { + name: "zero attempt number", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 0, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + errFragment: "attempt number is zero", + }, + { + name: "zero coordinator", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 0, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + errFragment: "attempt coordinator member index is zero", + }, + { + name: "coordinator not included", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 3, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + errFragment: "attempt coordinator [3] is not included", + }, + { + name: "member both included and excluded", + request: &NativeExecutionFFISigningRequest{ + GroupSize: 3, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + ExcludedMembersIndexes: []group.MemberIndex{2}, + }, + }, + errFragment: "member [2] is both included and excluded in attempt", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := includedMembersFromRequest(tc.request) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), tc.errFragment) { + t.Fatalf( + "unexpected error\nexpected to contain: [%v]\nactual: [%v]", + tc.errFragment, + err, + ) + } + }) + } +} + +func TestBuildTaggedTBTCSignerSyntheticRoundContributions(t *testing.T) { + roundState := &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + MessageDigestHex: "aabbccdd", + } + + contributionsFirst, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + []group.MemberIndex{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected synthetic contribution error: [%v]", err) + } + + contributionsSecond, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundState, + []group.MemberIndex{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected synthetic contribution error: [%v]", err) + } + + if len(contributionsFirst) != 3 { + t.Fatalf( + "unexpected contribution count\nexpected: [%v]\nactual: [%v]", + 3, + len(contributionsFirst), + ) + } + + expectedIdentifiers := []uint16{1, 2, 3} + for i, contribution := range contributionsFirst { + if contribution.Identifier != expectedIdentifiers[i] { + t.Fatalf( + "unexpected contribution identifier at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedIdentifiers[i], + contribution.Identifier, + ) + } + + if len(contribution.Data) != 32 { + t.Fatalf( + "unexpected contribution size at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + 32, + len(contribution.Data), + ) + } + + if !bytes.Equal(contribution.Data, contributionsSecond[i].Data) { + t.Fatalf("expected deterministic contribution at index [%d]", i) + } + } + + roundStateChanged := &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-2", + MessageDigestHex: "aabbccdd", + } + contributionsChanged, err := buildTaggedTBTCSignerSyntheticRoundContributions( + roundStateChanged, + []group.MemberIndex{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected synthetic contribution error: [%v]", err) + } + + if bytes.Equal(contributionsFirst[0].Data, contributionsChanged[0].Data) { + t.Fatal("expected contribution data to change when round metadata changes") + } +} + +func TestBuildTaggedTBTCSignerSyntheticRoundContributions_RejectsInvalidInput(t *testing.T) { + testCases := []struct { + name string + roundState *NativeTBTCSignerRoundState + members []group.MemberIndex + }{ + { + name: "nil round state", + roundState: nil, + members: []group.MemberIndex{1, 2}, + }, + { + name: "empty session id", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "", + RoundID: "round-1", + MessageDigestHex: "aa", + }, + members: []group.MemberIndex{1, 2}, + }, + { + name: "empty round id", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "", + MessageDigestHex: "aa", + }, + members: []group.MemberIndex{1, 2}, + }, + { + name: "empty message digest", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + MessageDigestHex: "", + }, + members: []group.MemberIndex{1, 2}, + }, + { + name: "zero member index", + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + MessageDigestHex: "aa", + }, + members: []group.MemberIndex{0, 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildTaggedTBTCSignerSyntheticRoundContributions( + tc.roundState, + tc.members, + ) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_ExchangesContributionsOverChannel( + t *testing.T, +) { + provider := local.Connect() + channel, err := provider.BroadcastChannelFor("tbtc-signer-bootstrap-round-plumbing-test") + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + engineByMember := map[group.MemberIndex]*deterministicBuildTaggedTBTCSignerBootstrapRoundEngine{ + 1: &deterministicBuildTaggedTBTCSignerBootstrapRoundEngine{ + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 1, + Data: []byte{0x11, 0x01}, + }, + }, + }, + 2: &deterministicBuildTaggedTBTCSignerBootstrapRoundEngine{ + roundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 2, + Data: []byte{0x22, 0x02}, + }, + }, + }, + } + + requestByMember := map[group.MemberIndex]*NativeExecutionFFISigningRequest{ + 1: { + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 2, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + 2: { + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 2, + GroupSize: 2, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + var wg sync.WaitGroup + signingErrors := make(chan error, len(requestByMember)) + + for memberIndex, request := range requestByMember { + engine := engineByMember[memberIndex] + wg.Add(1) + + go func( + signingRequest *NativeExecutionFFISigningRequest, + signingEngine NativeTBTCSignerEngine, + ) { + defer wg.Done() + + signingErrors <- executeBuildTaggedTBTCSignerBootstrapCoarseRound( + ctx, + signingRequest, + "group-1", + signingEngine, + nil, + nil, + ) + }(request, engine) + } + + wg.Wait() + close(signingErrors) + + for signingErr := range signingErrors { + if signingErr != nil { + t.Fatalf("unexpected signing error: [%v]", signingErr) + } + } + + for memberIndex, engine := range engineByMember { + finalizeInputs := engine.finalizeInputs() + if len(finalizeInputs) != 2 { + t.Fatalf( + "unexpected finalize input count for member [%v]\nexpected: [%v]\nactual: [%v]", + memberIndex, + 2, + len(finalizeInputs), + ) + } + + if finalizeInputs[0].Identifier != 1 || finalizeInputs[1].Identifier != 2 { + t.Fatalf( + "unexpected finalize identifiers for member [%v]\nexpected: [1 2]\nactual: [%v %v]", + memberIndex, + finalizeInputs[0].Identifier, + finalizeInputs[1].Identifier, + ) + } + + if len(finalizeInputs[0].Data) == 0 || len(finalizeInputs[1].Data) == 0 { + t.Fatalf("expected non-empty finalize contribution data for member [%v]", memberIndex) + } + + if !bytes.Equal(finalizeInputs[0].Data, []byte{0x11, 0x01}) { + t.Fatalf( + "unexpected contribution data for identifier 1, member [%v]\nexpected: [%x]\nactual: [%x]", + memberIndex, + []byte{0x11, 0x01}, + finalizeInputs[0].Data, + ) + } + + if !bytes.Equal(finalizeInputs[1].Data, []byte{0x22, 0x02}) { + t.Fatalf( + "unexpected contribution data for identifier 2, member [%v]\nexpected: [%x]\nactual: [%x]", + memberIndex, + []byte{0x22, 0x02}, + finalizeInputs[1].Data, + ) + } + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_UsesThresholdCohortOverFullGroup( + t *testing.T, +) { + provider := local.Connect() + channel, err := provider.BroadcastChannelFor("tbtc-signer-bootstrap-round-threshold-cohort-test") + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + engineByMember := map[group.MemberIndex]*mockBuildTaggedTBTCSignerEngine{ + 1: { + startRoundState: &NativeTBTCSignerRoundState{ + SessionID: "session-threshold", + RoundID: "round-threshold", + RequiredContributions: 2, + MessageDigestHex: "0011", + SigningParticipants: []uint16{1, 3}, + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 1, + Data: []byte{0x11, 0x01}, + }, + }, + }, + 3: { + startRoundState: &NativeTBTCSignerRoundState{ + SessionID: "session-threshold", + RoundID: "round-threshold", + RequiredContributions: 2, + MessageDigestHex: "0011", + SigningParticipants: []uint16{1, 3}, + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 3, + Data: []byte{0x33, 0x03}, + }, + }, + }, + } + + requestByMember := map[group.MemberIndex]*NativeExecutionFFISigningRequest{ + 1: { + Message: big.NewInt(123), + SessionID: "session-threshold", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 3}, + }, + }, + 3: { + Message: big.NewInt(123), + SessionID: "session-threshold", + MemberIndex: 3, + GroupSize: 3, + DishonestThreshold: 1, + Channel: channel, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 3}, + }, + }, + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + var wg sync.WaitGroup + signingErrors := make(chan error, len(requestByMember)) + + for memberIndex, request := range requestByMember { + engine := engineByMember[memberIndex] + wg.Add(1) + + go func( + signingRequest *NativeExecutionFFISigningRequest, + signingEngine NativeTBTCSignerEngine, + ) { + defer wg.Done() + + signingErrors <- executeBuildTaggedTBTCSignerBootstrapCoarseRound( + ctx, + signingRequest, + "group-1", + signingEngine, + nil, + nil, + ) + }(request, engine) + } + + wg.Wait() + close(signingErrors) + + for signingErr := range signingErrors { + if signingErr != nil { + t.Fatalf("unexpected signing error: [%v]", signingErr) + } + } + + expectedSigningParticipants := []uint16{1, 3} + for memberIndex, engine := range engineByMember { + if !reflect.DeepEqual(engine.startSigningParticipants, expectedSigningParticipants) { + t.Fatalf( + "unexpected StartSignRound signing participants for member [%v]\nexpected: [%v]\nactual: [%v]", + memberIndex, + expectedSigningParticipants, + engine.startSigningParticipants, + ) + } + + if len(engine.finalizeInputs) != 2 { + t.Fatalf( + "unexpected finalize input count for member [%v]\nexpected: [%v]\nactual: [%v]", + memberIndex, + 2, + len(engine.finalizeInputs), + ) + } + + if engine.finalizeInputs[0].Identifier != 1 || engine.finalizeInputs[1].Identifier != 3 { + t.Fatalf( + "unexpected finalize identifiers for member [%v]\nexpected: [1 3]\nactual: [%v %v]", + memberIndex, + engine.finalizeInputs[0].Identifier, + engine.finalizeInputs[1].Identifier, + ) + } + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_FailsWhenRoundStateSigningParticipantsMismatch( + t *testing.T, +) { + request := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 2, + DishonestThreshold: 1, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + } + + engine := &mockBuildTaggedTBTCSignerEngine{ + startRoundState: &NativeTBTCSignerRoundState{ + SessionID: "session-1", + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + SigningParticipants: []uint16{1, 3}, + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: 1, + Data: []byte{0x11, 0x01}, + }, + }, + } + + err := executeBuildTaggedTBTCSignerBootstrapCoarseRound( + context.Background(), + request, + "group-1", + engine, + nil, + nil, + ) + if err == nil { + t.Fatal("expected error") + } + + expectedErrFragment := "start sign round returned unexpected signing participant" + if !strings.Contains(err.Error(), expectedErrFragment) { + t.Fatalf( + "unexpected error\nexpected to contain: [%v]\nactual: [%v]", + expectedErrFragment, + err, + ) + } +} + +func TestExecuteBuildTaggedTBTCSignerBootstrapCoarseRound_FailsWhenRoundStateSigningParticipantsMissing( + t *testing.T, +) { + request := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 2, + DishonestThreshold: 1, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + } + + engine := &mockBuildTaggedTBTCSignerEngine{ + startSignRoundFn: func( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + ) (*NativeTBTCSignerRoundState, error) { + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "0011", + OwnContribution: &NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{0x11, 0x01}, + }, + }, nil + }, + } + + err := executeBuildTaggedTBTCSignerBootstrapCoarseRound( + context.Background(), + request, + "group-1", + engine, + nil, + nil, + ) + if err == nil { + t.Fatal("expected error") + } + + expectedErrFragment := "start sign round returned unexpected signing participants count" + if !strings.Contains(err.Error(), expectedErrFragment) { + t.Fatalf( + "unexpected error\nexpected to contain: [%v]\nactual: [%v]", + expectedErrFragment, + err, + ) + } +} + +func TestBuildTaggedTBTCSignerRoundKeyGroup(t *testing.T) { + testCases := []struct { + name string + payload *NativeTBTCSignerMaterialPayload + dkgResult *NativeTBTCSignerDKGResult + acceptScaffoldOptIn bool + expected string + substituted bool + expectError bool + expectScaffoldRefuse bool + }{ + { + name: "exact match", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "group-1", + }, + expected: "group-1", + substituted: false, + }, + { + name: "legacy source mismatch refused without opt-in", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "legacy-group", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "dkg-group", + }, + expectError: true, + expectScaffoldRefuse: true, + }, + { + name: "legacy source mismatch uses dkg key group with opt-in", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "legacy-group", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "dkg-group", + }, + acceptScaffoldOptIn: true, + expected: "dkg-group", + substituted: true, + }, + { + name: "non-legacy source mismatch rejects", + payload: &NativeTBTCSignerMaterialPayload{ + KeyGroup: "legacy-group", + KeyGroupSource: "dkg-persisted", + }, + dkgResult: &NativeTBTCSignerDKGResult{ + KeyGroup: "dkg-group", + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.acceptScaffoldOptIn { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + } else { + // Force the env to "" so a stray external value from a + // containing process cannot suppress the scaffold refusal + // during this test case. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "") + } + + actual, substituted, err := buildTaggedTBTCSignerRoundKeyGroup(tc.payload, tc.dkgResult) + if tc.expectError { + if err == nil { + t.Fatal("expected error") + } + + if tc.expectScaffoldRefuse && + !strings.Contains(err.Error(), AcceptScaffoldKeyGroupEnvVar) { + t.Fatalf( + "expected scaffold-refusal error referencing %s; got: [%v]", + AcceptScaffoldKeyGroupEnvVar, + err, + ) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actual != tc.expected { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + tc.expected, + actual, + ) + } + + if substituted != tc.substituted { + t.Fatalf( + "unexpected substitution flag\nexpected: [%v]\nactual: [%v]", + tc.substituted, + substituted, + ) + } + }) + } +} + +func TestIsBuildTaggedTBTCSignerBootstrapVersion(t *testing.T) { + testCases := []struct { + name string + version string + expected bool + }{ + { + name: "valid exact bootstrap", + version: "tbtc-signer/0.1.0-bootstrap", + expected: true, + }, + { + name: "valid bootstrap dotted suffix", + version: "tbtc-signer/0.1.0-bootstrap.1", + expected: true, + }, + { + name: "invalid non-bootstrap prerelease", + version: "tbtc-signer/0.1.0-post-bootstrap", + expected: false, + }, + { + name: "invalid major version one", + version: "tbtc-signer/1.0.0-bootstrap", + expected: false, + }, + { + name: "invalid missing prerelease", + version: "tbtc-signer/0.1.0", + expected: false, + }, + { + name: "invalid malformed core semver", + version: "tbtc-signer/0.1-bootstrap", + expected: false, + }, + { + name: "invalid prefix", + version: "other/0.1.0-bootstrap", + expected: false, + }, + { + name: "invalid uppercase bootstrap token", + version: "tbtc-signer/0.1.0-Bootstrap", + expected: false, + }, + { + name: "invalid substring trap", + version: "tbtc-signer/0.1.0-post-bootstrap-cleanup", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := isBuildTaggedTBTCSignerBootstrapVersion(tc.version) + if actual != tc.expected { + t.Fatalf( + "unexpected bootstrap version classification\nversion: [%s]\nexpected: [%v]\nactual: [%v]", + tc.version, + tc.expected, + actual, + ) + } + }) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath( + t *testing.T, +) { + engine := &mockBuildTaggedTBTCSignerEngine{} + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1"}`), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call in tbtc-signer path") + } + + if engine.runDKGSessionID != "session-1" { + t.Fatalf( + "unexpected RunDKG session ID\nexpected: [%v]\nactual: [%v]", + "session-1", + engine.runDKGSessionID, + ) + } + + if engine.runDKGThreshold != 2 { + t.Fatalf( + "unexpected RunDKG threshold\nexpected: [%v]\nactual: [%v]", + 2, + engine.runDKGThreshold, + ) + } + + if len(engine.runDKGParticipants) != 3 { + t.Fatalf( + "unexpected RunDKG participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(engine.runDKGParticipants), + ) + } + + if engine.startCalled { + t.Fatal("did not expect StartSignRound call for non-bootstrap tbtc-signer version") + } + + if engine.finalizeCalled { + t.Fatal("did not expect FinalizeSignRound call for non-bootstrap tbtc-signer version") + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion( + t *testing.T, +) { + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x11), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1"}`), + }, + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if signature == nil { + t.Fatal("expected signature") + } + + marshaledSignature, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + expectedSignature := buildTaggedTBTCSignerValidTestSignature(0x11) + if !bytes.Equal(marshaledSignature, expectedSignature) { + t.Fatalf( + "unexpected signature bytes\nexpected: [%x]\nactual: [%x]", + expectedSignature, + marshaledSignature, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call in bootstrap tbtc-signer path") + } + + if !engine.startCalled { + t.Fatal("expected StartSignRound call in bootstrap tbtc-signer path") + } + + if engine.startSessionID != "session-1" { + t.Fatalf( + "unexpected StartSignRound session ID\nexpected: [%v]\nactual: [%v]", + "session-1", + engine.startSessionID, + ) + } + + if engine.startMemberID != 1 { + t.Fatalf( + "unexpected StartSignRound member identifier\nexpected: [%v]\nactual: [%v]", + 1, + engine.startMemberID, + ) + } + + if engine.startKeyGroup != "group-1" { + t.Fatalf( + "unexpected StartSignRound key group\nexpected: [%v]\nactual: [%v]", + "group-1", + engine.startKeyGroup, + ) + } + + expectedSigningParticipants := []uint16{1, 2, 3} + if !reflect.DeepEqual(engine.startSigningParticipants, expectedSigningParticipants) { + t.Fatalf( + "unexpected StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedSigningParticipants, + engine.startSigningParticipants, + ) + } + + if !engine.finalizeCalled { + t.Fatal("expected FinalizeSignRound call in bootstrap tbtc-signer path") + } + + if len(observedEvents) != 0 { + t.Fatalf( + "did not expect fallback events\nactual: [%v]", + observedEvents, + ) + } + + if len(observedCoarseSignatureEvents) != 1 { + t.Fatalf( + "unexpected coarse signature event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedCoarseSignatureEvents), + ) + } + + if observedCoarseSignatureEvents[0].SessionID != "session-1" { + t.Fatalf( + "unexpected coarse signature session ID\nexpected: [%s]\nactual: [%s]", + "session-1", + observedCoarseSignatureEvents[0].SessionID, + ) + } + + if observedCoarseSignatureEvents[0].EngineVersion != "tbtc-signer/0.1.0-bootstrap" { + t.Fatalf( + "unexpected coarse signature engine version\nexpected: [%s]\nactual: [%s]", + "tbtc-signer/0.1.0-bootstrap", + observedCoarseSignatureEvents[0].EngineVersion, + ) + } + + if engine.finalizeSessionID != "session-1" { + t.Fatalf( + "unexpected FinalizeSignRound session ID\nexpected: [%v]\nactual: [%v]", + "session-1", + engine.finalizeSessionID, + ) + } + + if len(engine.finalizeInputs) != 3 { + t.Fatalf( + "unexpected FinalizeSignRound contributions count\nexpected: [%v]\nactual: [%v]", + 3, + len(engine.finalizeInputs), + ) + } + + expectedIdentifiers := []uint16{1, 2, 3} + for i, contribution := range engine.finalizeInputs { + if contribution.Identifier != expectedIdentifiers[i] { + t.Fatalf( + "unexpected contribution identifier at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedIdentifiers[i], + contribution.Identifier, + ) + } + + if len(contribution.Data) == 0 { + t.Fatalf("expected non-empty contribution data at index [%d]", i) + } + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_InvalidCoarseSignatureFallsBack( + t *testing.T, +) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + finalizeSignature: []byte{0xaa}, + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call in bootstrap path") + } + + if !engine.startCalled { + t.Fatal("expected StartSignRound call in bootstrap path") + } + + if !engine.finalizeCalled { + t.Fatal("expected FinalizeSignRound call in bootstrap path") + } + + if len(observedEvents) != 1 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedEvents), + ) + } + + if !strings.Contains( + observedEvents[0].Reason, + "cannot decode tbtc-signer coarse signature", + ) { + t.Fatalf( + "expected fallback reason to include decode failure\nactual: [%s]", + observedEvents[0].Reason, + ) + } + + if len(observedCoarseSignatureEvents) != 0 { + t.Fatalf( + "did not expect coarse signature events\nactual: [%v]", + observedCoarseSignatureEvents, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_RefusesScaffoldMaterialWithoutOptIn( + t *testing.T, +) { + // Closes the persistence-vs-execution gap: even when the native + // tbtc-signer engine is registered (scaffold material on disk from a + // previous opted-in session is enough to reach this point), the FFI + // signing path must refuse to feed RunDKG placeholder participant + // pubkeys without an active operator opt-in. Force the env var off so + // any value inherited from the test runner's containing process cannot + // suppress the refusal. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "") + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + runDKGResult: &NativeTBTCSignerDKGResult{ + SessionID: "session-scaffold-refused", + KeyGroup: "group-from-dkg", + ParticipantCount: 3, + Threshold: 2, + CreatedAtUnix: 1, + }, + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x22), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + if err := RegisterNativeTBTCSignerEngine(engine); err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-scaffold-refused", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte( + `{"keyGroup":"legacy-wallet-derived","keyGroupSource":"legacy-wallet-pubkey"}`, + ), + }, + }) + if err == nil { + t.Fatal("expected scaffold-refusal error from FFI signing path") + } + if signature != nil { + t.Fatal("expected nil signature when scaffold path is refused") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected ErrNativeCryptographyUnavailable wrap; got: [%v]", + err, + ) + } + if !strings.Contains(err.Error(), AcceptScaffoldKeyGroupEnvVar) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + AcceptScaffoldKeyGroupEnvVar, + err, + ) + } + if !strings.Contains(err.Error(), NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + err, + ) + } + + if engine.runDKGCalled { + t.Fatal( + "RunDKG must not be called when the scaffold opt-in flag is unset; " + + "refusing before the placeholder participant pubkeys are built " + + "is the whole point of the fence", + ) + } + if engine.startCalled { + t.Fatal("StartSignRound must not be called when the scaffold path is refused") + } + if engine.finalizeCalled { + t.Fatal( + "FinalizeSignRound must not be called when the scaffold path is refused", + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_LegacyKeyGroupSourceUsesRunDKGResult( + t *testing.T, +) { + // Scaffold-era path: legacy-wallet-pubkey signer material is refused by + // default; the operator opt-in via AcceptScaffoldKeyGroupEnvVar is what + // lets this test exercise the substitution. Production deployments must + // never set this. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + runDKGResult: &NativeTBTCSignerDKGResult{ + SessionID: "session-1", + KeyGroup: "group-from-dkg", + ParticipantCount: 3, + Threshold: 2, + CreatedAtUnix: 1, + }, + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x22), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte( + `{"keyGroup":"legacy-wallet-derived","keyGroupSource":"legacy-wallet-pubkey"}`, + ), + }, + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if signature == nil { + t.Fatal("expected signature") + } + + marshaledSignature, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + expectedSignature := buildTaggedTBTCSignerValidTestSignature(0x22) + if !bytes.Equal(marshaledSignature, expectedSignature) { + t.Fatalf( + "unexpected signature bytes\nexpected: [%x]\nactual: [%x]", + expectedSignature, + marshaledSignature, + ) + } + + if !engine.startCalled { + t.Fatal("expected StartSignRound call in bootstrap path") + } + + if engine.startKeyGroup != "group-from-dkg" { + t.Fatalf( + "unexpected StartSignRound key group\nexpected: [%v]\nactual: [%v]", + "group-from-dkg", + engine.startKeyGroup, + ) + } + + expectedSigningParticipants := []uint16{1, 2, 3} + if !reflect.DeepEqual(engine.startSigningParticipants, expectedSigningParticipants) { + t.Fatalf( + "unexpected StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedSigningParticipants, + engine.startSigningParticipants, + ) + } + + if !engine.finalizeCalled { + t.Fatal("expected FinalizeSignRound call in bootstrap path") + } + + if len(observedCoarseSignatureEvents) != 1 { + t.Fatalf( + "unexpected coarse signature event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedCoarseSignatureEvents), + ) + } + + if observedCoarseSignatureEvents[0].KeyGroupSource != "legacy-wallet-pubkey" { + t.Fatalf( + "unexpected coarse signature key group source\nexpected: [%s]\nactual: [%s]", + "legacy-wallet-pubkey", + observedCoarseSignatureEvents[0].KeyGroupSource, + ) + } + + if observedCoarseSignatureEvents[0].EngineVersion != "tbtc-signer/0.1.0-bootstrap" { + t.Fatalf( + "unexpected coarse signature engine version\nexpected: [%s]\nactual: [%s]", + "tbtc-signer/0.1.0-bootstrap", + observedCoarseSignatureEvents[0].EngineVersion, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_KeyGroupMismatchNonLegacySourceSkipsCoarseRound( + t *testing.T, +) { + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + runDKGResult: &NativeTBTCSignerDKGResult{ + SessionID: "session-1", + KeyGroup: "group-from-dkg", + ParticipantCount: 3, + Threshold: 2, + CreatedAtUnix: 1, + }, + } + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte( + `{"keyGroup":"legacy-wallet-derived","keyGroupSource":"dkg-persisted"}`, + ), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if engine.startCalled { + t.Fatal("did not expect StartSignRound call for non-legacy key-group mismatch") + } + + if engine.finalizeCalled { + t.Fatal("did not expect FinalizeSignRound call for non-legacy key-group mismatch") + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_NoEngineNoLegacyShare( + t *testing.T, +) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the engine-unavailable + no-legacy-share branch which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var observedEvents []NativeTBTCSignerFallbackEvent + err := RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if len(observedEvents) != 1 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedEvents), + ) + } + + event := observedEvents[0] + if event.SessionID != "session-1" { + t.Fatalf( + "unexpected fallback session ID\nexpected: [%s]\nactual: [%s]", + "session-1", + event.SessionID, + ) + } + + if event.KeyGroupSource != "legacy-wallet-pubkey" { + t.Fatalf( + "unexpected fallback key group source\nexpected: [%s]\nactual: [%s]", + "legacy-wallet-pubkey", + event.KeyGroupSource, + ) + } + + if event.LegacyPrivateKeyShareExists { + t.Fatal("expected fallback event without legacy private key share") + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_AttemptVariationRunDKGConflictFallsBack( + t *testing.T, +) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var firstParticipants []NativeTBTCSignerDKGParticipant + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0", + runDKGFn: func( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) { + if firstParticipants == nil { + firstParticipants = append( + []NativeTBTCSignerDKGParticipant{}, + participants..., + ) + + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil + } + + if !reflect.DeepEqual(participants, firstParticipants) { + return nil, errors.New("session_conflict") + } + + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil + }, + } + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + baseRequest := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + } + + _, err = primitive.Sign(nil, nil, baseRequest) + if err == nil { + t.Fatal("expected first signing error due to legacy fallback without private key share") + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected first signing error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + secondRequest := *baseRequest + secondRequest.Attempt = &Attempt{ + Number: 2, + CoordinatorMemberIndex: 1, + ExcludedMembersIndexes: []group.MemberIndex{3}, + } + + _, err = primitive.Sign(nil, nil, &secondRequest) + if err == nil { + t.Fatal("expected second signing error due to legacy fallback without private key share") + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected second signing error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if len(observedEvents) != 2 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 2, + len(observedEvents), + ) + } + + if !strings.Contains(observedEvents[1].Reason, "session_conflict") { + t.Fatalf( + "expected second fallback reason to include session_conflict\nactual: [%s]", + observedEvents[1].Reason, + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_AttemptVariationStartSignRoundConflictFallsBack( + t *testing.T, +) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var firstSigningParticipants []uint16 + var observedSigningParticipants [][]uint16 + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x44), + runDKGFn: func( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) { + return &NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil + }, + startSignRoundFn: func( + sessionID string, + _ uint16, + _ []byte, + _ string, + signingParticipants []uint16, + ) (*NativeTBTCSignerRoundState, error) { + observedSigningParticipants = append( + observedSigningParticipants, + append([]uint16{}, signingParticipants...), + ) + + if firstSigningParticipants == nil { + firstSigningParticipants = append( + []uint16{}, + signingParticipants..., + ) + } else if !reflect.DeepEqual(signingParticipants, firstSigningParticipants) { + return nil, errors.New("session_conflict") + } + + return &NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: "round-1", + RequiredContributions: 2, + MessageDigestHex: "00", + SigningParticipants: append( + []uint16{}, + signingParticipants..., + ), + }, nil + }, + } + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + var observedCoarseSignatureEvents []NativeTBTCSignerCoarseSignatureEvent + err = RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + observedCoarseSignatureEvents = append( + observedCoarseSignatureEvents, + event, + ) + }, + ) + if err != nil { + t.Fatalf("unexpected coarse observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + baseRequest := &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{"keyGroup":"group-1","keyGroupSource":"legacy-wallet-pubkey"}`), + }, + } + + firstSignature, err := primitive.Sign(nil, nil, baseRequest) + if err != nil { + t.Fatalf("unexpected first signing error: [%v]", err) + } + if firstSignature == nil { + t.Fatal("expected first signature") + } + + secondRequest := *baseRequest + secondRequest.Attempt = &Attempt{ + Number: 2, + CoordinatorMemberIndex: 1, + ExcludedMembersIndexes: []group.MemberIndex{2}, + } + + _, err = primitive.Sign(nil, nil, &secondRequest) + if err == nil { + t.Fatal("expected second signing error due to legacy fallback without private key share") + } + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected second signing error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if len(observedSigningParticipants) != 2 { + t.Fatalf( + "unexpected StartSignRound call count\nexpected: [%d]\nactual: [%d]", + 2, + len(observedSigningParticipants), + ) + } + + expectedFirstParticipants := []uint16{1, 2, 3} + if !reflect.DeepEqual(observedSigningParticipants[0], expectedFirstParticipants) { + t.Fatalf( + "unexpected first StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedFirstParticipants, + observedSigningParticipants[0], + ) + } + + expectedSecondParticipants := []uint16{1, 3} + if !reflect.DeepEqual(observedSigningParticipants[1], expectedSecondParticipants) { + t.Fatalf( + "unexpected second StartSignRound signing participants\nexpected: [%v]\nactual: [%v]", + expectedSecondParticipants, + observedSigningParticipants[1], + ) + } + + if len(observedEvents) != 1 { + t.Fatalf( + "unexpected fallback event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedEvents), + ) + } + + if !strings.Contains(observedEvents[0].Reason, "session_conflict") { + t.Fatalf( + "expected fallback reason to include session_conflict\nactual: [%s]", + observedEvents[0].Reason, + ) + } + + if len(observedCoarseSignatureEvents) != 1 { + t.Fatalf( + "unexpected coarse signature event count\nexpected: [%d]\nactual: [%d]", + 1, + len(observedCoarseSignatureEvents), + ) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_InvalidAttemptPolicy_DoesNotFallback( + t *testing.T, +) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the FFI flow's invalid-attempt-policy branch, which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(3) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + privateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + privateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + signerMaterialPayload, err := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(privateKeySharePayload), + }) + if err != nil { + t.Fatalf("cannot marshal signer material payload: [%v]", err) + } + + testCases := []struct { + name string + attempt *Attempt + }{ + { + name: "zero attempt number", + attempt: &Attempt{ + Number: 0, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + { + name: "zero coordinator", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 0, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + { + name: "coordinator not included", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 3, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }, + { + name: "included members empty after exclusions", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + ExcludedMembersIndexes: []group.MemberIndex{1, 2, 3}, + }, + }, + { + name: "member included and excluded", + attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 2, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + ExcludedMembersIndexes: []group.MemberIndex{2}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: signerMaterialPayload, + }, + Attempt: tc.attempt, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !errors.Is(err, ErrInvalidSigningAttemptPolicy) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrInvalidSigningAttemptPolicy, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected not to include: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if engine.runDKGCalled { + t.Fatal("did not expect RunDKG call for invalid attempt policy") + } + + if len(observedEvents) != 0 { + t.Fatalf( + "did not expect fallback events\nactual: [%v]", + observedEvents, + ) + } + }) + } +} + +func TestIsBuildTaggedTBTCSignerConsumedAttemptReplayError(t *testing.T) { + // Locking-mutex-free unit-coverage for the replay detector. Each case + // constructs an error in the shape that flows out of the FFI bridge today + // and asserts the detector's decision. + cases := []struct { + name string + err error + match bool + }{ + { + name: "nil error is not a replay", + err: nil, + match: false, + }, + { + name: "structured code wins over message wording", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: buildTaggedTBTCSignerConsumedAttemptReplayErrorCode, + Message: "rust message wording is not load-bearing here", + }, + ), + match: true, + }, + { + name: "structured but different code does not match", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "session_conflict", + Message: "attempt_id [x] already consumed for sign attempt in session [y]", + }, + ), + match: false, + }, + { + name: "legacy substring still matches when code is missing", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "", + Message: "attempt_id [att-1] already consumed for sign attempt in session [sess-1]", + }, + ), + match: true, + }, + { + // Pre-dedicated-variant signer builds route the replay path + // through Validation, so the code on the wire is + // validation_error and only the message identifies replay. + name: "validation_error code with legacy wording is a replay", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "validation_error", + Message: "attempt_id [att-1] already consumed for sign attempt in session [sess-1]", + }, + ), + match: true, + }, + { + // A validation_error that is NOT the replay path must not be + // flagged as a replay even if surrounding error chain noise + // happens to mention attempt_id elsewhere. + name: "validation_error without legacy wording is not a replay", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "validation_error", + Message: "session_id is empty", + }, + ), + match: false, + }, + { + name: "legacy substring still matches when error is a plain wrapper", + err: fmt.Errorf( + "native FROST bridge operation failed: tbtc-signer bridge " + + "operation [StartSignRound] failed: [validation_error: " + + "attempt_id [att-1] already consumed for sign attempt in " + + "session [sess-1]]", + ), + match: true, + }, + { + name: "unrelated error is not a replay", + err: fmt.Errorf( + "%w: tbtc-signer bridge operation [StartSignRound] failed: [%w]", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: "validation_error", + Message: "session_id is empty", + }, + ), + match: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isBuildTaggedTBTCSignerConsumedAttemptReplayError(tc.err); got != tc.match { + t.Fatalf( + "detector returned [%v]; expected [%v] for error [%v]", + got, + tc.match, + tc.err, + ) + } + }) + } +} + +func TestBuildTaggedTBTCSignerErrorPayload(t *testing.T) { + cases := []struct { + name string + payload []byte + code string + // Substring expected in the rendered Message. Empty means we don't + // assert beyond Code presence. + messageSubstring string + }{ + { + name: "decodes structured envelope", + payload: []byte(`{"code":"consumed_attempt_replay","message":"attempt_id [a] already consumed"}`), + code: "consumed_attempt_replay", + messageSubstring: "already consumed", + }, + { + name: "legacy validation_error code is preserved", + payload: []byte(`{"code":"validation_error","message":"session_id is empty"}`), + code: "validation_error", + messageSubstring: "session_id is empty", + }, + { + name: "message-only payload leaves Code empty", + payload: []byte(`{"message":"opaque message"}`), + code: "", + messageSubstring: "opaque message", + }, + { + name: "completely empty envelope surfaces the raw payload", + payload: []byte(`{}`), + code: "", + messageSubstring: "empty error payload", + }, + { + name: "non-JSON payload is reported as a decode failure", + payload: []byte(`not json`), + code: "", + messageSubstring: "cannot decode error payload", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + structured := buildTaggedTBTCSignerErrorPayload(tc.payload) + if structured == nil { + t.Fatal("expected non-nil structured error") + } + if structured.Code != tc.code { + t.Fatalf( + "unexpected Code\nexpected: [%s]\nactual: [%s]", + tc.code, + structured.Code, + ) + } + if tc.messageSubstring != "" && + !strings.Contains(structured.Message, tc.messageSubstring) { + t.Fatalf( + "Message missing expected substring\nexpected substring: [%s]\nactual: [%s]", + tc.messageSubstring, + structured.Message, + ) + } + }) + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_ConsumedAttemptReplay_DoesNotFallback( + t *testing.T, +) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the FFI flow's consumed-attempt-replay branch, which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(3) + if err != nil { + t.Fatalf("failed loading key share fixtures: [%v]", err) + } + + privateKeyShare := tecdsa.NewPrivateKeyShare(fixtures[0]) + privateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling private key share: [%v]", err) + } + + signerMaterialPayload, err := json.Marshal(&NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + KeyGroupSource: NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(privateKeySharePayload), + }) + if err != nil { + t.Fatalf("cannot marshal signer material payload: [%v]", err) + } + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + startErr: errors.New( + "validation: attempt_id [11] already consumed for sign attempt in session [session-1]", + ), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + err = RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + var observedEvents []NativeTBTCSignerFallbackEvent + err = RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + observedEvents = append(observedEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected observer registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + _, err = primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-1", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: signerMaterialPayload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2}, + }, + }) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !errors.Is(err, ErrConsumedSigningAttemptReplay) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrConsumedSigningAttemptReplay, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected not to include: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !engine.runDKGCalled { + t.Fatal("expected RunDKG call before consumed-attempt replay rejection") + } + + if len(observedEvents) != 0 { + t.Fatalf( + "did not expect fallback events\nactual: [%v]", + observedEvents, + ) + } + + if !strings.Contains(err.Error(), "already consumed for sign attempt") { + t.Fatalf( + "expected replay fragment in error message\nactual: [%v]", + err, + ) + } +} diff --git a/pkg/frost/signing/native_frost_engine_frost_native.go b/pkg/frost/signing/native_frost_engine_frost_native.go new file mode 100644 index 0000000000..757212b0e5 --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_frost_native.go @@ -0,0 +1,105 @@ +//go:build frost_native + +package signing + +import ( + "fmt" +) + +const ( + // NativeSignerMaterialFormatFrostUniFFIV2 carries fully-native signer + // material required to execute two-round FROST signing. + NativeSignerMaterialFormatFrostUniFFIV2 = "frost-uniffi-v2" +) + +var nativeFROSTSigningEngine NativeFROSTSigningEngine + +// NativeFROSTKeyPackage carries native key-package bytes and participant +// identifier expected by the native FROST engine. +type NativeFROSTKeyPackage struct { + Identifier string `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeFROSTPublicKeyPackage carries native public-key-package payload. +type NativeFROSTPublicKeyPackage struct { + VerifyingShares map[string]string `json:"verifyingShares"` + VerifyingKey string `json:"verifyingKey"` +} + +// NativeFROSTNonces is round-one signer-local nonce material. +type NativeFROSTNonces struct { + Data []byte `json:"data"` +} + +// NativeFROSTCommitment is round-one commitment shared with the group. +type NativeFROSTCommitment struct { + Identifier string `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeFROSTSigningPackage is coordinator-computed package used in round two. +type NativeFROSTSigningPackage struct { + Data []byte `json:"data"` +} + +// NativeFROSTSignatureShare is round-two signature share. +type NativeFROSTSignatureShare struct { + Identifier string `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeFROSTSigningEngine executes cryptographic round operations needed by +// the native FROST signing protocol. +type NativeFROSTSigningEngine interface { + GenerateNoncesAndCommitments( + keyPackage *NativeFROSTKeyPackage, + ) (*NativeFROSTNonces, *NativeFROSTCommitment, error) + NewSigningPackage( + message []byte, + commitments []*NativeFROSTCommitment, + ) (*NativeFROSTSigningPackage, error) + Sign( + signingPackage *NativeFROSTSigningPackage, + nonces *NativeFROSTNonces, + keyPackage *NativeFROSTKeyPackage, + ) (*NativeFROSTSignatureShare, error) + Aggregate( + signingPackage *NativeFROSTSigningPackage, + signatureShares []*NativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, + ) ([]byte, error) +} + +// RegisterNativeFROSTSigningEngine registers the native FROST cryptographic +// engine used by the tagged native-signing primitive. +func RegisterNativeFROSTSigningEngine( + engine NativeFROSTSigningEngine, +) error { + if engine == nil { + return fmt.Errorf("native FROST signing engine is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeFROSTSigningEngine = engine + + return nil +} + +// UnregisterNativeFROSTSigningEngine clears native FROST signing engine +// registration. +func UnregisterNativeFROSTSigningEngine() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeFROSTSigningEngine = nil +} + +func currentNativeFROSTSigningEngine() NativeFROSTSigningEngine { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeFROSTSigningEngine +} diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go new file mode 100644 index 0000000000..c36af4b0a6 --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -0,0 +1,1032 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +/* +#cgo CFLAGS: -std=c11 +#cgo linux LDFLAGS: -ldl +#cgo freebsd LDFLAGS: -ldl +#include +#include +#include +#include + +typedef struct { + uint8_t* ptr; + size_t len; +} TbtcBuffer; + +typedef struct { + int32_t status_code; + TbtcBuffer buffer; +} TbtcSignerResult; + +typedef TbtcSignerResult (*tbtc_version_fn)(void); +typedef TbtcSignerResult (*tbtc_run_dkg_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_start_sign_round_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_finalize_sign_round_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerResult (*tbtc_build_taproot_tx_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef void (*tbtc_free_buffer_fn)(uint8_t* ptr, size_t len); + +static TbtcSignerResult unavailable_tbtc_signer_result(void) { + TbtcSignerResult result; + result.status_code = -1; + result.buffer.ptr = NULL; + result.buffer.len = 0; + return result; +} + +static TbtcSignerResult tbtc_signer_version(void) { + tbtc_version_fn version = (tbtc_version_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_version" + ); + if (version == NULL) { + return unavailable_tbtc_signer_result(); + } + + return version(); +} + +static TbtcSignerResult tbtc_signer_run_dkg(const uint8_t* request_ptr, size_t request_len) { + tbtc_run_dkg_fn run_dkg = (tbtc_run_dkg_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_run_dkg" + ); + if (run_dkg == NULL) { + return unavailable_tbtc_signer_result(); + } + + return run_dkg(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_start_sign_round(const uint8_t* request_ptr, size_t request_len) { + tbtc_start_sign_round_fn start_sign_round = (tbtc_start_sign_round_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_start_sign_round" + ); + if (start_sign_round == NULL) { + return unavailable_tbtc_signer_result(); + } + + return start_sign_round(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_finalize_sign_round(const uint8_t* request_ptr, size_t request_len) { + tbtc_finalize_sign_round_fn finalize_sign_round = (tbtc_finalize_sign_round_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_finalize_sign_round" + ); + if (finalize_sign_round == NULL) { + return unavailable_tbtc_signer_result(); + } + + return finalize_sign_round(request_ptr, request_len); +} + +static TbtcSignerResult tbtc_signer_build_taproot_tx(const uint8_t* request_ptr, size_t request_len) { + tbtc_build_taproot_tx_fn build_taproot_tx = (tbtc_build_taproot_tx_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_build_taproot_tx" + ); + if (build_taproot_tx == NULL) { + return unavailable_tbtc_signer_result(); + } + + return build_taproot_tx(request_ptr, request_len); +} + +static void tbtc_signer_free_buffer(uint8_t* ptr, size_t len) { + tbtc_free_buffer_fn free_buffer = (tbtc_free_buffer_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_free_buffer" + ); + if (free_buffer != NULL) { + free_buffer(ptr, len); + } +} +*/ +import "C" + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "unsafe" +) + +type buildTaggedTBTCSignerEngine struct{} +type buildTaggedTBTCSignerErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// buildTaggedTBTCSignerStructuredError carries the FFI error envelope's +// structured fields so callers can match on Code via `errors.As` rather than +// substring-matching the rendered error string. Older signer builds may +// return errors without a Code field; this type still wraps them via the +// Message field, and consumers should treat an empty Code as a fall-back +// signal to apply legacy substring matching. +type buildTaggedTBTCSignerStructuredError struct { + Code string + Message string +} + +func (e *buildTaggedTBTCSignerStructuredError) Error() string { + if e == nil { + return "" + } + if e.Code != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Message) + } + return e.Message +} + +type buildTaggedTBTCSignerRunDKGRequest struct { + SessionID string `json:"session_id"` + Participants []buildTaggedTBTCSignerDKGParticipant `json:"participants"` + Threshold uint16 `json:"threshold"` +} + +type buildTaggedTBTCSignerDKGParticipant struct { + Identifier uint16 `json:"identifier"` + PublicKeyHex string `json:"public_key_hex"` +} + +type buildTaggedTBTCSignerRunDKGResponse struct { + SessionID string `json:"session_id"` + KeyGroup string `json:"key_group"` + ParticipantCount uint16 `json:"participant_count"` + Threshold uint16 `json:"threshold"` + CreatedAtUnix uint64 `json:"created_at_unix"` +} + +type buildTaggedTBTCSignerStartSignRoundRequest struct { + SessionID string `json:"session_id"` + MemberIdentifier uint16 `json:"member_identifier"` + MessageHex string `json:"message_hex"` + KeyGroup string `json:"key_group"` + SigningParticipants []uint16 `json:"signing_participants,omitempty"` +} + +type buildTaggedTBTCSignerStartSignRoundResponse struct { + SessionID string `json:"session_id"` + RoundID string `json:"round_id"` + RequiredContributions uint16 `json:"required_contributions"` + MessageDigestHex string `json:"message_digest_hex"` + SigningParticipants []uint16 `json:"signing_participants,omitempty"` + OwnContribution *buildTaggedTBTCSignerFinalizeRoundContribution `json:"own_contribution"` +} + +type buildTaggedTBTCSignerFinalizeSignRoundRequest struct { + SessionID string `json:"session_id"` + RoundContributions []buildTaggedTBTCSignerFinalizeRoundContribution `json:"round_contributions"` +} + +type buildTaggedTBTCSignerFinalizeRoundContribution struct { + Identifier uint16 `json:"identifier"` + SignatureShareHex string `json:"signature_share_hex"` +} + +type buildTaggedTBTCSignerFinalizeSignRoundResponse struct { + SessionID string `json:"session_id"` + RoundID string `json:"round_id"` + SignatureHex string `json:"signature_hex"` +} + +type buildTaggedTBTCSignerBuildTaprootTxRequest struct { + SessionID string `json:"session_id"` + Inputs []buildTaggedTBTCSignerBuildTaprootTxInput `json:"inputs"` + Outputs []buildTaggedTBTCSignerBuildTaprootTxOutput `json:"outputs"` + ScriptTreeHex *string `json:"script_tree_hex,omitempty"` +} + +type buildTaggedTBTCSignerBuildTaprootTxInput struct { + TxIDHex string `json:"txid_hex"` + Vout uint32 `json:"vout"` + ValueSats uint64 `json:"value_sats"` +} + +type buildTaggedTBTCSignerBuildTaprootTxOutput struct { + ScriptPubKeyHex string `json:"script_pubkey_hex"` + ValueSats uint64 `json:"value_sats"` +} + +type buildTaggedTBTCSignerBuildTaprootTxResponse struct { + SessionID string `json:"session_id"` + TxHex string `json:"tx_hex"` +} + +const buildTaggedTBTCSignerUnavailableStatusCode = -1 + +func registerBuildTaggedNativeFROSTSigningEngine() error { + return RegisterNativeTBTCSignerEngine(&buildTaggedTBTCSignerEngine{}) +} + +func (bttse *buildTaggedTBTCSignerEngine) Version() (string, error) { + responsePayload, err := callBuildTaggedTBTCSignerVersion() + if err != nil { + return "", err + } + + version := string(responsePayload) + if version == "" { + return "", buildTaggedTBTCSignerOperationError( + "Version", + "response version is empty", + ) + } + + return version, nil +} + +func (bttse *buildTaggedTBTCSignerEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + requestPayload, err := buildTaggedTBTCSignerRunDKGRequestPayload( + sessionID, + participants, + threshold, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerRunDKG(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerRunDKGResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, +) (*NativeTBTCSignerRoundState, error) { + requestPayload, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + sessionID, + memberIdentifier, + message, + keyGroup, + signingParticipants, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerStartSignRound(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerStartSignRoundResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, +) ([]byte, error) { + requestPayload, err := buildTaggedTBTCSignerFinalizeSignRoundRequestPayload( + sessionID, + roundContributions, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerFinalizeSignRound(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerFinalizeSignRoundResponse(responsePayload) +} + +func (bttse *buildTaggedTBTCSignerEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + requestPayload, err := buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + sessionID, + inputs, + outputs, + scriptTreeHex, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerBuildTaprootTx(requestPayload) + if err != nil { + return nil, err + } + + return decodeBuildTaggedTBTCSignerBuildTaprootTxResponse(responsePayload) +} + +func buildTaggedTBTCSignerUnavailableError(operation string) error { + return fmt.Errorf( + "%w: tbtc-signer bridge operation [%v] is unavailable; link libfrost_tbtc", + ErrNativeCryptographyUnavailable, + operation, + ) +} + +func buildTaggedTBTCSignerOperationError( + operation string, + message string, +) error { + return fmt.Errorf( + "%w: tbtc-signer bridge operation [%v] failed: [%s]", + ErrNativeBridgeOperationFailed, + operation, + message, + ) +} + +func buildTaggedTBTCSignerRunDKGRequestPayload( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "session ID is empty", + ) + } + + if len(participants) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "participants are empty", + ) + } + + if threshold == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "threshold is zero", + ) + } + + requestParticipants := make( + []buildTaggedTBTCSignerDKGParticipant, + 0, + len(participants), + ) + + for i, participant := range participants { + if participant.Identifier == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("participant [%d] identifier is zero", i), + ) + } + + if participant.PublicKeyHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("participant [%d] public key hex is empty", i), + ) + } + + requestParticipants = append( + requestParticipants, + buildTaggedTBTCSignerDKGParticipant{ + Identifier: participant.Identifier, + PublicKeyHex: participant.PublicKeyHex, + }, + ) + } + + request := buildTaggedTBTCSignerRunDKGRequest{ + SessionID: sessionID, + Participants: requestParticipants, + Threshold: threshold, + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerRunDKGResponse( + responsePayload []byte, +) (*NativeTBTCSignerDKGResult, error) { + var response buildTaggedTBTCSignerRunDKGResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response session ID is empty", + ) + } + + if response.KeyGroup == "" { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response key group is empty", + ) + } + + if response.ParticipantCount == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response participant count is zero", + ) + } + + if response.Threshold == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "RunDKG", + "response threshold is zero", + ) + } + + return &NativeTBTCSignerDKGResult{ + SessionID: response.SessionID, + KeyGroup: response.KeyGroup, + ParticipantCount: response.ParticipantCount, + Threshold: response.Threshold, + CreatedAtUnix: response.CreatedAtUnix, + }, nil +} + +func buildTaggedTBTCSignerStartSignRoundRequestPayload( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "session ID is empty", + ) + } + + if keyGroup == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "key group is empty", + ) + } + + if memberIdentifier == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "member identifier is zero", + ) + } + + seenParticipants := make(map[uint16]struct{}, len(signingParticipants)) + for i, participant := range signingParticipants { + if participant == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("signing participant [%d] is zero", i), + ) + } + if _, ok := seenParticipants[participant]; ok { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("signing participant [%d] is duplicated", participant), + ) + } + seenParticipants[participant] = struct{}{} + } + + request := buildTaggedTBTCSignerStartSignRoundRequest{ + SessionID: sessionID, + MemberIdentifier: memberIdentifier, + MessageHex: hex.EncodeToString(message), + KeyGroup: keyGroup, + SigningParticipants: append([]uint16{}, signingParticipants...), + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerStartSignRoundResponse( + responsePayload []byte, +) (*NativeTBTCSignerRoundState, error) { + var response buildTaggedTBTCSignerStartSignRoundResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response session ID is empty", + ) + } + + if response.RoundID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response round ID is empty", + ) + } + + if response.MessageDigestHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response message digest is empty", + ) + } + + seenSigningParticipants := make(map[uint16]struct{}, len(response.SigningParticipants)) + for _, participant := range response.SigningParticipants { + if participant == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response signing participant is zero", + ) + } + + if _, ok := seenSigningParticipants[participant]; ok { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf("response signing participant [%d] is duplicated", participant), + ) + } + + seenSigningParticipants[participant] = struct{}{} + } + + var ownContribution *NativeTBTCSignerRoundContribution + if response.OwnContribution != nil { + if response.OwnContribution.Identifier == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response own contribution identifier is zero", + ) + } + + if response.OwnContribution.SignatureShareHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + "response own contribution signature share is empty", + ) + } + + ownContributionData, err := hex.DecodeString( + response.OwnContribution.SignatureShareHex, + ) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StartSignRound", + fmt.Sprintf( + "response own contribution signature share is invalid hex: %v", + err, + ), + ) + } + + ownContribution = &NativeTBTCSignerRoundContribution{ + Identifier: response.OwnContribution.Identifier, + Data: ownContributionData, + } + } + + return &NativeTBTCSignerRoundState{ + SessionID: response.SessionID, + RoundID: response.RoundID, + RequiredContributions: response.RequiredContributions, + MessageDigestHex: response.MessageDigestHex, + SigningParticipants: append([]uint16{}, response.SigningParticipants...), + OwnContribution: ownContribution, + }, nil +} + +func buildTaggedTBTCSignerFinalizeSignRoundRequestPayload( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + "session ID is empty", + ) + } + + if len(roundContributions) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + "round contributions are empty", + ) + } + + payloadContributions := make( + []buildTaggedTBTCSignerFinalizeRoundContribution, + 0, + len(roundContributions), + ) + + for i, contribution := range roundContributions { + if len(contribution.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("round contribution [%d] data is empty", i), + ) + } + + payloadContributions = append( + payloadContributions, + buildTaggedTBTCSignerFinalizeRoundContribution{ + Identifier: contribution.Identifier, + SignatureShareHex: hex.EncodeToString(contribution.Data), + }, + ) + } + + request := buildTaggedTBTCSignerFinalizeSignRoundRequest{ + SessionID: sessionID, + RoundContributions: payloadContributions, + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerFinalizeSignRoundResponse( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerFinalizeSignRoundResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SignatureHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + "response signature is empty", + ) + } + + signature, err := hex.DecodeString(response.SignatureHex) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "FinalizeSignRound", + fmt.Sprintf("response signature is invalid hex: %v", err), + ) + } + + return signature, nil +} + +func buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "session ID is empty", + ) + } + + if len(inputs) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "inputs are empty", + ) + } + + if len(outputs) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "outputs are empty", + ) + } + + requestInputs := make( + []buildTaggedTBTCSignerBuildTaprootTxInput, + 0, + len(inputs), + ) + for i, input := range inputs { + if input.TxIDHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("input [%d] txid hex is empty", i), + ) + } + + requestInputs = append( + requestInputs, + buildTaggedTBTCSignerBuildTaprootTxInput{ + TxIDHex: input.TxIDHex, + Vout: input.Vout, + ValueSats: input.ValueSats, + }, + ) + } + + requestOutputs := make( + []buildTaggedTBTCSignerBuildTaprootTxOutput, + 0, + len(outputs), + ) + for i, output := range outputs { + if output.ScriptPubKeyHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("output [%d] script pubkey hex is empty", i), + ) + } + + requestOutputs = append( + requestOutputs, + buildTaggedTBTCSignerBuildTaprootTxOutput{ + ScriptPubKeyHex: output.ScriptPubKeyHex, + ValueSats: output.ValueSats, + }, + ) + } + + var requestScriptTreeHex *string + if scriptTreeHex != nil { + if *scriptTreeHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "script tree hex is empty", + ) + } + + copied := *scriptTreeHex + requestScriptTreeHex = &copied + } + + request := buildTaggedTBTCSignerBuildTaprootTxRequest{ + SessionID: sessionID, + Inputs: requestInputs, + Outputs: requestOutputs, + ScriptTreeHex: requestScriptTreeHex, + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("cannot marshal request: %v", err), + ) + } + + return payload, nil +} + +func decodeBuildTaggedTBTCSignerBuildTaprootTxResponse( + responsePayload []byte, +) (*NativeTBTCSignerTxResult, error) { + var response buildTaggedTBTCSignerBuildTaprootTxResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + if response.SessionID == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "response session ID is empty", + ) + } + + if response.TxHex == "" { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + "response tx hex is empty", + ) + } + + if _, err := hex.DecodeString(response.TxHex); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "BuildTaprootTx", + fmt.Sprintf("response tx hex is invalid: %v", err), + ) + } + + return &NativeTBTCSignerTxResult{ + SessionID: response.SessionID, + TxHex: response.TxHex, + }, nil +} + +func callBuildTaggedTBTCSignerVersion() ([]byte, error) { + result := C.tbtc_signer_version() + return parseBuildTaggedTBTCSignerResult("Version", result) +} + +func callBuildTaggedTBTCSignerRunDKG( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "RunDKG", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_run_dkg(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerStartSignRound( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "StartSignRound", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_start_sign_round(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerFinalizeSignRound( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "FinalizeSignRound", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_finalize_sign_round(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerBuildTaprootTx( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "BuildTaprootTx", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_build_taproot_tx(requestPtr, requestLen) + }, + ) +} + +func callBuildTaggedTBTCSignerOperation( + operation string, + requestPayload []byte, + call func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult, +) ([]byte, error) { + if len(requestPayload) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + "request payload is empty", + ) + } + + requestPtr := C.CBytes(requestPayload) + defer C.free(requestPtr) + + result := call((*C.uint8_t)(requestPtr), C.size_t(len(requestPayload))) + return parseBuildTaggedTBTCSignerResult(operation, result) +} + +func parseBuildTaggedTBTCSignerResult( + operation string, + result C.TbtcSignerResult, +) ([]byte, error) { + // The C wrapper guards against a missing `frost_tbtc_free_buffer` symbol + // but not against a NULL buffer pointer. Status code -1 paths (FFI lib + // unavailable) and any future path that returns an empty buffer can leave + // `result.buffer.ptr == nil`, so skip the deferred free in that case to + // avoid handing a NULL pointer to Rust's `frost_tbtc_free_buffer`. + if result.buffer.ptr != nil { + defer C.tbtc_signer_free_buffer(result.buffer.ptr, result.buffer.len) + } + + statusCode := int32(result.status_code) + + var payload []byte + if result.buffer.ptr != nil && result.buffer.len > 0 { + payload = C.GoBytes(unsafe.Pointer(result.buffer.ptr), C.int(result.buffer.len)) + } + + statusErr := buildTaggedTBTCSignerResultStatusError(operation, statusCode, payload) + if statusErr != nil { + return nil, statusErr + } + + if len(payload) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + operation, + "response payload is empty", + ) + } + + return payload, nil +} + +func buildTaggedTBTCSignerResultStatusError( + operation string, + statusCode int32, + payload []byte, +) error { + if statusCode == buildTaggedTBTCSignerUnavailableStatusCode { + return buildTaggedTBTCSignerUnavailableError(operation) + } + + if statusCode != 0 { + structured := buildTaggedTBTCSignerErrorPayload(payload) + return fmt.Errorf( + "%w: tbtc-signer bridge operation [%v] failed: [%w]", + ErrNativeBridgeOperationFailed, + operation, + structured, + ) + } + + return nil +} + +// buildTaggedTBTCSignerErrorPayload decodes the FFI error envelope into a +// structured form so callers can match on the `Code` field via `errors.As` +// rather than rely on substring matching against the rendered error string. +// Decode failures and missing-fields edge cases are surfaced via the +// `Message` field with `Code` left empty so consumers know to fall back to +// legacy matching. +func buildTaggedTBTCSignerErrorPayload(payload []byte) *buildTaggedTBTCSignerStructuredError { + var errorResponse buildTaggedTBTCSignerErrorResponse + if err := json.Unmarshal(payload, &errorResponse); err != nil { + return &buildTaggedTBTCSignerStructuredError{ + Message: fmt.Sprintf( + "cannot decode error payload [%x]: %v", + payload, + err, + ), + } + } + + if errorResponse.Code == "" && errorResponse.Message == "" { + return &buildTaggedTBTCSignerStructuredError{ + Message: fmt.Sprintf("empty error payload: [%s]", string(payload)), + } + } + + return &buildTaggedTBTCSignerStructuredError{ + Code: errorResponse.Code, + Message: errorResponse.Message, + } +} diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go new file mode 100644 index 0000000000..941688275a --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go @@ -0,0 +1,954 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestRegisterBuildTaggedTBTCSignerEngine(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + t.Cleanup(func() { + UnregisterNativeTBTCSignerEngine() + }) + + err := registerBuildTaggedNativeFROSTSigningEngine() + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + engine := currentNativeTBTCSignerEngine() + if engine == nil { + t.Fatal("expected native tbtc-signer engine registration") + } + + _, err = engine.StartSignRound( + "session-1", + 1, + []byte("message"), + "key-group", + nil, + ) + if err == nil { + t.Fatal("expected unavailable tbtc-signer bridge error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("unexpected bridge error: [%v]", err) + } + + _, err = engine.BuildTaprootTx( + "session-1", + []NativeTBTCSignerTxInput{ + {TxIDHex: "11", Vout: 0, ValueSats: 1}, + }, + []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014", ValueSats: 1}, + }, + nil, + ) + if err == nil { + t.Fatal("expected unavailable tbtc-signer build-tx bridge error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + versionedEngine, ok := engine.(interface { + Version() (string, error) + }) + if !ok { + t.Fatal("expected versioned native tbtc-signer engine") + } + + _, err = versionedEngine.Version() + if err == nil { + t.Fatal("expected unavailable tbtc-signer version bridge error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } +} + +func TestBuildTaggedTBTCSignerResultStatusError_Unavailable(t *testing.T) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + buildTaggedTBTCSignerUnavailableStatusCode, + nil, + ) + if err == nil { + t.Fatal("expected unavailable error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected native cryptography unavailable error: [%v], got [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "did not expect native bridge operation failed error: [%v]", + err, + ) + } +} + +func TestBuildTaggedTBTCSignerResultStatusError_BridgeOperationFailure(t *testing.T) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + 2, + []byte(`{"code":"validation","message":"invalid input"}`), + ) + if err == nil { + t.Fatal("expected bridge operation failure error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } + + if !strings.Contains(err.Error(), "validation: invalid input") { + t.Fatalf("unexpected bridge operation error: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerResultStatusError_BridgeOperationFailure_InvalidPayload( + t *testing.T, +) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + 2, + []byte("{invalid-json"), + ) + if err == nil { + t.Fatal("expected bridge operation failure error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !strings.Contains(err.Error(), "cannot decode error payload") { + t.Fatalf("unexpected bridge operation error: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerResultStatusError_BridgeOperationFailure_FallbackPayload( + t *testing.T, +) { + err := buildTaggedTBTCSignerResultStatusError( + "BuildTaprootTx", + 2, + []byte(`{"code":"internal_error","message":"failed to encode error"}`), + ) + if err == nil { + t.Fatal("expected bridge operation failure error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if !strings.Contains(err.Error(), "internal_error: failed to encode error") { + t.Fatalf("unexpected bridge operation error: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerRunDKGRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerRunDKGRequestPayload( + "session-1", + []NativeTBTCSignerDKGParticipant{ + { + Identifier: 1, + PublicKeyHex: "02aa", + }, + { + Identifier: 2, + PublicKeyHex: "02bb", + }, + }, + 2, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerRunDKGRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + request.SessionID, + ) + } + + if request.Threshold != 2 { + t.Fatalf( + "unexpected threshold\nexpected: [%v]\nactual: [%v]", + 2, + request.Threshold, + ) + } + + if len(request.Participants) != 2 { + t.Fatalf( + "unexpected participants count\nexpected: [%v]\nactual: [%v]", + 2, + len(request.Participants), + ) + } + + if request.Participants[0].Identifier != 1 { + t.Fatalf( + "unexpected participant identifier\nexpected: [%v]\nactual: [%v]", + 1, + request.Participants[0].Identifier, + ) + } + + if request.Participants[0].PublicKeyHex != "02aa" { + t.Fatalf( + "unexpected participant public key hex\nexpected: [%v]\nactual: [%v]", + "02aa", + request.Participants[0].PublicKeyHex, + ) + } +} + +func TestBuildTaggedTBTCSignerRunDKGRequestPayload_RejectsInvalidInput(t *testing.T) { + testCases := []struct { + name string + sessionID string + participants []NativeTBTCSignerDKGParticipant + threshold uint16 + }{ + { + name: "empty session id", + sessionID: "", + participants: []NativeTBTCSignerDKGParticipant{{Identifier: 1, PublicKeyHex: "02aa"}}, + threshold: 2, + }, + { + name: "empty participants", + sessionID: "session-1", + participants: nil, + threshold: 2, + }, + { + name: "zero threshold", + sessionID: "session-1", + participants: []NativeTBTCSignerDKGParticipant{ + {Identifier: 1, PublicKeyHex: "02aa"}, + }, + threshold: 0, + }, + { + name: "participant zero identifier", + sessionID: "session-1", + participants: []NativeTBTCSignerDKGParticipant{ + {Identifier: 0, PublicKeyHex: "02aa"}, + }, + threshold: 1, + }, + { + name: "participant empty public key hex", + sessionID: "session-1", + participants: []NativeTBTCSignerDKGParticipant{ + {Identifier: 1, PublicKeyHex: ""}, + }, + threshold: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildTaggedTBTCSignerRunDKGRequestPayload( + tc.sessionID, + tc.participants, + tc.threshold, + ) + if err == nil { + t.Fatal("expected payload build error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerRunDKGResponse(t *testing.T) { + result, err := decodeBuildTaggedTBTCSignerRunDKGResponse( + []byte( + `{"session_id":"session-1","key_group":"group-1","participant_count":3,"threshold":2,"created_at_unix":123456789}`, + ), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if result.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + result.SessionID, + ) + } + + if result.KeyGroup != "group-1" { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + "group-1", + result.KeyGroup, + ) + } + + if result.ParticipantCount != 3 { + t.Fatalf( + "unexpected participant count\nexpected: [%v]\nactual: [%v]", + 3, + result.ParticipantCount, + ) + } + + if result.Threshold != 2 { + t.Fatalf( + "unexpected threshold\nexpected: [%v]\nactual: [%v]", + 2, + result.Threshold, + ) + } + + if result.CreatedAtUnix != 123456789 { + t.Fatalf( + "unexpected created-at unix\nexpected: [%v]\nactual: [%v]", + 123456789, + result.CreatedAtUnix, + ) + } +} + +func TestBuildTaggedTBTCSignerStartSignRoundRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + "session-1", + 3, + []byte{0xab, 0xcd}, + "key-group-1", + []uint16{1, 2, 3}, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerStartSignRoundRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + request.SessionID, + ) + } + + if request.MessageHex != "abcd" { + t.Fatalf( + "unexpected message hex\nexpected: [%v]\nactual: [%v]", + "abcd", + request.MessageHex, + ) + } + + if request.KeyGroup != "key-group-1" { + t.Fatalf( + "unexpected key group\nexpected: [%v]\nactual: [%v]", + "key-group-1", + request.KeyGroup, + ) + } + + if request.MemberIdentifier != 3 { + t.Fatalf( + "unexpected member identifier\nexpected: [%v]\nactual: [%v]", + 3, + request.MemberIdentifier, + ) + } + + if len(request.SigningParticipants) != 3 { + t.Fatalf( + "unexpected signing participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(request.SigningParticipants), + ) + } + + expectedSigningParticipants := []uint16{1, 2, 3} + for i := range expectedSigningParticipants { + if request.SigningParticipants[i] != expectedSigningParticipants[i] { + t.Fatalf( + "unexpected signing participant at index [%d]\nexpected: [%v]\nactual: [%v]", + i, + expectedSigningParticipants[i], + request.SigningParticipants[i], + ) + } + } +} + +func TestBuildTaggedTBTCSignerStartSignRoundRequestPayload_EmptySessionID(t *testing.T) { + _, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + "", + 1, + []byte{0xab}, + "key-group-1", + nil, + ) + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestBuildTaggedTBTCSignerStartSignRoundRequestPayload_ZeroMemberID(t *testing.T) { + _, err := buildTaggedTBTCSignerStartSignRoundRequestPayload( + "session-1", + 0, + []byte{0xab}, + "key-group-1", + nil, + ) + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestBuildTaggedTBTCSignerFinalizeSignRoundRequestPayload(t *testing.T) { + payload, err := buildTaggedTBTCSignerFinalizeSignRoundRequestPayload( + "session-1", + []NativeTBTCSignerRoundContribution{ + { + Identifier: 7, + Data: []byte{0xde, 0xad}, + }, + }, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerFinalizeSignRoundRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + request.SessionID, + ) + } + + if len(request.RoundContributions) != 1 { + t.Fatalf( + "unexpected contribution count\nexpected: [%v]\nactual: [%v]", + 1, + len(request.RoundContributions), + ) + } + + if request.RoundContributions[0].Identifier != 7 { + t.Fatalf( + "unexpected contribution identifier\nexpected: [%v]\nactual: [%v]", + 7, + request.RoundContributions[0].Identifier, + ) + } + + if request.RoundContributions[0].SignatureShareHex != "dead" { + t.Fatalf( + "unexpected contribution signature share\nexpected: [%v]\nactual: [%v]", + "dead", + request.RoundContributions[0].SignatureShareHex, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse(t *testing.T) { + roundState, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,2,3],"own_contribution":{"identifier":3,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if roundState.SessionID != "session-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-1", + roundState.SessionID, + ) + } + + if roundState.RoundID != "round-1" { + t.Fatalf( + "unexpected round id\nexpected: [%v]\nactual: [%v]", + "round-1", + roundState.RoundID, + ) + } + + if roundState.RequiredContributions != 2 { + t.Fatalf( + "unexpected required contributions\nexpected: [%v]\nactual: [%v]", + 2, + roundState.RequiredContributions, + ) + } + + if roundState.MessageDigestHex != "abcd" { + t.Fatalf( + "unexpected message digest hex\nexpected: [%v]\nactual: [%v]", + "abcd", + roundState.MessageDigestHex, + ) + } + + if len(roundState.SigningParticipants) != 3 { + t.Fatalf( + "unexpected signing participants count\nexpected: [%v]\nactual: [%v]", + 3, + len(roundState.SigningParticipants), + ) + } + + if roundState.OwnContribution == nil { + t.Fatal("expected own contribution in round state response") + } + + if roundState.OwnContribution.Identifier != 3 { + t.Fatalf( + "unexpected own contribution identifier\nexpected: [%v]\nactual: [%v]", + 3, + roundState.OwnContribution.Identifier, + ) + } + + expectedOwnContributionData := []byte{0xde, 0xad, 0xbe, 0xef} + if len(roundState.OwnContribution.Data) != len(expectedOwnContributionData) { + t.Fatalf( + "unexpected own contribution data length\nexpected: [%v]\nactual: [%v]", + len(expectedOwnContributionData), + len(roundState.OwnContribution.Data), + ) + } + + for i := range roundState.OwnContribution.Data { + if roundState.OwnContribution.Data[i] != expectedOwnContributionData[i] { + t.Fatalf( + "unexpected own contribution byte at index [%d]\nexpected: [%x]\nactual: [%x]", + i, + expectedOwnContributionData[i], + roundState.OwnContribution.Data[i], + ) + } + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse_RejectsZeroSigningParticipant( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,0,3],"own_contribution":{"identifier":3,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse_RejectsDuplicateSigningParticipant( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,2,2],"own_contribution":{"identifier":3,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerStartSignRoundResponse_RejectsZeroOwnContributionIdentifier( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerStartSignRoundResponse( + []byte( + `{"session_id":"session-1","round_id":"round-1","required_contributions":2,"message_digest_hex":"abcd","signing_participants":[1,2,3],"own_contribution":{"identifier":0,"signature_share_hex":"deadbeef"}}`, + ), + ) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } +} + +func TestDecodeBuildTaggedTBTCSignerFinalizeSignRoundResponse(t *testing.T) { + signature, err := decodeBuildTaggedTBTCSignerFinalizeSignRoundResponse( + []byte(`{"session_id":"session-1","round_id":"round-1","signature_hex":"deadbeef"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + expectedSignature := []byte{0xde, 0xad, 0xbe, 0xef} + if len(signature) != len(expectedSignature) { + t.Fatalf( + "unexpected signature length\nexpected: [%v]\nactual: [%v]", + len(expectedSignature), + len(signature), + ) + } + + for i := range signature { + if signature[i] != expectedSignature[i] { + t.Fatalf( + "unexpected signature byte at index [%d]\nexpected: [%x]\nactual: [%x]", + i, + expectedSignature[i], + signature[i], + ) + } + } +} + +func TestBuildTaggedTBTCSignerBuildTaprootTxRequestPayload(t *testing.T) { + scriptTreeHex := "deadbeef" + + payload, err := buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + "session-buildtx-1", + []NativeTBTCSignerTxInput{ + { + TxIDHex: strings.Repeat("11", 32), + Vout: 3, + ValueSats: 1000, + }, + }, + []NativeTBTCSignerTxOutput{ + { + ScriptPubKeyHex: "0014deadbeef", + ValueSats: 900, + }, + }, + &scriptTreeHex, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerBuildTaprootTxRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + + if request.SessionID != "session-buildtx-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-buildtx-1", + request.SessionID, + ) + } + + if len(request.Inputs) != 1 { + t.Fatalf( + "unexpected input count\nexpected: [%v]\nactual: [%v]", + 1, + len(request.Inputs), + ) + } + + if request.Inputs[0].TxIDHex != strings.Repeat("11", 32) { + t.Fatalf( + "unexpected input txid\nexpected: [%v]\nactual: [%v]", + strings.Repeat("11", 32), + request.Inputs[0].TxIDHex, + ) + } + + if len(request.Outputs) != 1 { + t.Fatalf( + "unexpected output count\nexpected: [%v]\nactual: [%v]", + 1, + len(request.Outputs), + ) + } + + if request.Outputs[0].ScriptPubKeyHex != "0014deadbeef" { + t.Fatalf( + "unexpected output script pubkey\nexpected: [%v]\nactual: [%v]", + "0014deadbeef", + request.Outputs[0].ScriptPubKeyHex, + ) + } + + if request.ScriptTreeHex == nil || *request.ScriptTreeHex != scriptTreeHex { + t.Fatal("expected script tree hex to be present and preserved") + } +} + +func TestBuildTaggedTBTCSignerBuildTaprootTxRequestPayload_RejectsInvalidInput( + t *testing.T, +) { + scriptTreeHex := "" + + testCases := []struct { + name string + sessionID string + inputs []NativeTBTCSignerTxInput + outputs []NativeTBTCSignerTxOutput + scriptTreeHex *string + }{ + { + name: "empty session id", + sessionID: "", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + }, + { + name: "empty inputs", + sessionID: "session-1", + inputs: nil, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + }, + { + name: "empty outputs", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: nil, + }, + { + name: "input txid empty", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: "", Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + }, + { + name: "output script empty", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "", ValueSats: 1}, + }, + }, + { + name: "script tree empty string", + sessionID: "session-1", + inputs: []NativeTBTCSignerTxInput{ + {TxIDHex: strings.Repeat("11", 32), Vout: 0, ValueSats: 1}, + }, + outputs: []NativeTBTCSignerTxOutput{ + {ScriptPubKeyHex: "0014aa", ValueSats: 1}, + }, + scriptTreeHex: &scriptTreeHex, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildTaggedTBTCSignerBuildTaprootTxRequestPayload( + tc.sessionID, + tc.inputs, + tc.outputs, + tc.scriptTreeHex, + ) + if err == nil { + t.Fatal("expected payload build error") + } + + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected native bridge operation failed error: [%v], got [%v]", + ErrNativeBridgeOperationFailed, + err, + ) + } + + if errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "did not expect native cryptography unavailable error: [%v]", + err, + ) + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerBuildTaprootTxResponse(t *testing.T) { + result, err := decodeBuildTaggedTBTCSignerBuildTaprootTxResponse( + []byte(`{"session_id":"session-buildtx-1","tx_hex":"deadbeef"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if result.SessionID != "session-buildtx-1" { + t.Fatalf( + "unexpected session id\nexpected: [%v]\nactual: [%v]", + "session-buildtx-1", + result.SessionID, + ) + } + + if result.TxHex != "deadbeef" { + t.Fatalf( + "unexpected tx hex\nexpected: [%v]\nactual: [%v]", + "deadbeef", + result.TxHex, + ) + } +} diff --git a/pkg/frost/signing/native_frost_engine_uniffi_frost_native.go b/pkg/frost/signing/native_frost_engine_uniffi_frost_native.go new file mode 100644 index 0000000000..9c2cbd0b0e --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_uniffi_frost_native.go @@ -0,0 +1,230 @@ +//go:build frost_native + +package signing + +import "fmt" + +type uniFFINativeFROSTCommitment struct { + Identifier string + Data []byte +} + +type uniFFINativeFROSTSignatureShare struct { + Identifier string + Data []byte +} + +type uniFFINativeFROSTBridge interface { + GenerateNoncesAndCommitments( + keyPackageIdentifier string, + keyPackageData []byte, + ) (noncesData []byte, commitmentIdentifier string, commitmentData []byte, err error) + NewSigningPackage( + message []byte, + commitments []uniFFINativeFROSTCommitment, + ) (signingPackageData []byte, err error) + Sign( + signingPackageData []byte, + noncesData []byte, + keyPackageIdentifier string, + keyPackageData []byte, + ) (signatureShareIdentifier string, signatureShareData []byte, err error) + Aggregate( + signingPackageData []byte, + signatureShares []uniFFINativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, + ) (signature []byte, err error) +} + +type uniFFINativeFROSTSigningEngine struct { + bridge uniFFINativeFROSTBridge +} + +func newUniFFINativeFROSTSigningEngine( + bridge uniFFINativeFROSTBridge, +) (NativeFROSTSigningEngine, error) { + if bridge == nil { + return nil, fmt.Errorf("uniffi native FROST bridge is nil") + } + + return &uniFFINativeFROSTSigningEngine{ + bridge: bridge, + }, nil +} + +func (unfse *uniFFINativeFROSTSigningEngine) GenerateNoncesAndCommitments( + keyPackage *NativeFROSTKeyPackage, +) (*NativeFROSTNonces, *NativeFROSTCommitment, error) { + if keyPackage == nil { + return nil, nil, fmt.Errorf("key package is nil") + } + + if keyPackage.Identifier == "" { + return nil, nil, fmt.Errorf("key package identifier is empty") + } + + if len(keyPackage.Data) == 0 { + return nil, nil, fmt.Errorf("key package data is empty") + } + + noncesData, commitmentIdentifier, commitmentData, err := unfse.bridge.GenerateNoncesAndCommitments( + keyPackage.Identifier, + append([]byte{}, keyPackage.Data...), + ) + if err != nil { + return nil, nil, err + } + + return &NativeFROSTNonces{ + Data: append([]byte{}, noncesData...), + }, &NativeFROSTCommitment{ + Identifier: commitmentIdentifier, + Data: append([]byte{}, commitmentData...), + }, nil +} + +func (unfse *uniFFINativeFROSTSigningEngine) NewSigningPackage( + message []byte, + commitments []*NativeFROSTCommitment, +) (*NativeFROSTSigningPackage, error) { + if len(commitments) == 0 { + return nil, fmt.Errorf("commitments are empty") + } + + bridgeCommitments := make([]uniFFINativeFROSTCommitment, 0, len(commitments)) + for i, commitment := range commitments { + if commitment == nil { + return nil, fmt.Errorf("commitment [%d] is nil", i) + } + + if commitment.Identifier == "" { + return nil, fmt.Errorf("commitment [%d] identifier is empty", i) + } + + if len(commitment.Data) == 0 { + return nil, fmt.Errorf("commitment [%d] data is empty", i) + } + + bridgeCommitments = append(bridgeCommitments, uniFFINativeFROSTCommitment{ + Identifier: commitment.Identifier, + Data: append([]byte{}, commitment.Data...), + }) + } + + signingPackageData, err := unfse.bridge.NewSigningPackage( + append([]byte{}, message...), + bridgeCommitments, + ) + if err != nil { + return nil, err + } + + return &NativeFROSTSigningPackage{ + Data: append([]byte{}, signingPackageData...), + }, nil +} + +func (unfse *uniFFINativeFROSTSigningEngine) Sign( + signingPackage *NativeFROSTSigningPackage, + nonces *NativeFROSTNonces, + keyPackage *NativeFROSTKeyPackage, +) (*NativeFROSTSignatureShare, error) { + if signingPackage == nil { + return nil, fmt.Errorf("signing package is nil") + } + + if len(signingPackage.Data) == 0 { + return nil, fmt.Errorf("signing package data is empty") + } + + if nonces == nil { + return nil, fmt.Errorf("nonces are nil") + } + + if len(nonces.Data) == 0 { + return nil, fmt.Errorf("nonces data is empty") + } + + if keyPackage == nil { + return nil, fmt.Errorf("key package is nil") + } + + if keyPackage.Identifier == "" { + return nil, fmt.Errorf("key package identifier is empty") + } + + if len(keyPackage.Data) == 0 { + return nil, fmt.Errorf("key package data is empty") + } + + identifier, signatureShareData, err := unfse.bridge.Sign( + append([]byte{}, signingPackage.Data...), + append([]byte{}, nonces.Data...), + keyPackage.Identifier, + append([]byte{}, keyPackage.Data...), + ) + if err != nil { + return nil, err + } + + return &NativeFROSTSignatureShare{ + Identifier: identifier, + Data: append([]byte{}, signatureShareData...), + }, nil +} + +func (unfse *uniFFINativeFROSTSigningEngine) Aggregate( + signingPackage *NativeFROSTSigningPackage, + signatureShares []*NativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) ([]byte, error) { + if signingPackage == nil { + return nil, fmt.Errorf("signing package is nil") + } + + if len(signingPackage.Data) == 0 { + return nil, fmt.Errorf("signing package data is empty") + } + + if len(signatureShares) == 0 { + return nil, fmt.Errorf("signature shares are empty") + } + + if publicKeyPackage == nil { + return nil, fmt.Errorf("public key package is nil") + } + + bridgeSignatureShares := make([]uniFFINativeFROSTSignatureShare, 0, len(signatureShares)) + for i, signatureShare := range signatureShares { + if signatureShare == nil { + return nil, fmt.Errorf("signature share [%d] is nil", i) + } + + if signatureShare.Identifier == "" { + return nil, fmt.Errorf("signature share [%d] identifier is empty", i) + } + + if len(signatureShare.Data) == 0 { + return nil, fmt.Errorf("signature share [%d] data is empty", i) + } + + bridgeSignatureShares = append( + bridgeSignatureShares, + uniFFINativeFROSTSignatureShare{ + Identifier: signatureShare.Identifier, + Data: append([]byte{}, signatureShare.Data...), + }, + ) + } + + signature, err := unfse.bridge.Aggregate( + append([]byte{}, signingPackage.Data...), + bridgeSignatureShares, + publicKeyPackage, + ) + if err != nil { + return nil, err + } + + return append([]byte{}, signature...), nil +} diff --git a/pkg/frost/signing/native_frost_engine_uniffi_frost_native_test.go b/pkg/frost/signing/native_frost_engine_uniffi_frost_native_test.go new file mode 100644 index 0000000000..ba263706c6 --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_uniffi_frost_native_test.go @@ -0,0 +1,246 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "errors" + "testing" +) + +type mockUniFFINativeFROSTBridge struct { + generateNoncesAndCommitmentsFn func( + keyPackageIdentifier string, + keyPackageData []byte, + ) ([]byte, string, []byte, error) + newSigningPackageFn func( + message []byte, + commitments []uniFFINativeFROSTCommitment, + ) ([]byte, error) + signFn func( + signingPackageData []byte, + noncesData []byte, + keyPackageIdentifier string, + keyPackageData []byte, + ) (string, []byte, error) + aggregateFn func( + signingPackageData []byte, + signatureShares []uniFFINativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, + ) ([]byte, error) +} + +func (munfsb *mockUniFFINativeFROSTBridge) GenerateNoncesAndCommitments( + keyPackageIdentifier string, + keyPackageData []byte, +) ([]byte, string, []byte, error) { + return munfsb.generateNoncesAndCommitmentsFn( + keyPackageIdentifier, + keyPackageData, + ) +} + +func (munfsb *mockUniFFINativeFROSTBridge) NewSigningPackage( + message []byte, + commitments []uniFFINativeFROSTCommitment, +) ([]byte, error) { + return munfsb.newSigningPackageFn(message, commitments) +} + +func (munfsb *mockUniFFINativeFROSTBridge) Sign( + signingPackageData []byte, + noncesData []byte, + keyPackageIdentifier string, + keyPackageData []byte, +) (string, []byte, error) { + return munfsb.signFn( + signingPackageData, + noncesData, + keyPackageIdentifier, + keyPackageData, + ) +} + +func (munfsb *mockUniFFINativeFROSTBridge) Aggregate( + signingPackageData []byte, + signatureShares []uniFFINativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) ([]byte, error) { + return munfsb.aggregateFn(signingPackageData, signatureShares, publicKeyPackage) +} + +func TestNewUniFFINativeFROSTSigningEngine_NilBridge(t *testing.T) { + _, err := newUniFFINativeFROSTSigningEngine(nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestUniFFINativeFROSTSigningEngine_GenerateNoncesAndCommitments(t *testing.T) { + var capturedIdentifier string + var capturedData []byte + + engine, err := newUniFFINativeFROSTSigningEngine(&mockUniFFINativeFROSTBridge{ + generateNoncesAndCommitmentsFn: func( + keyPackageIdentifier string, + keyPackageData []byte, + ) ([]byte, string, []byte, error) { + capturedIdentifier = keyPackageIdentifier + capturedData = append([]byte{}, keyPackageData...) + return []byte{0x01, 0x02}, "id-1", []byte{0x03, 0x04}, nil + }, + }) + if err != nil { + t.Fatalf("unexpected constructor error: [%v]", err) + } + + nonces, commitment, err := engine.GenerateNoncesAndCommitments( + &NativeFROSTKeyPackage{ + Identifier: "member-1", + Data: []byte{0xaa, 0xbb}, + }, + ) + if err != nil { + t.Fatalf("unexpected generation error: [%v]", err) + } + + if capturedIdentifier != "member-1" { + t.Fatalf( + "unexpected key package identifier\nexpected: [%v]\nactual: [%v]", + "member-1", + capturedIdentifier, + ) + } + + if !bytes.Equal(capturedData, []byte{0xaa, 0xbb}) { + t.Fatalf( + "unexpected key package data\nexpected: [%x]\nactual: [%x]", + []byte{0xaa, 0xbb}, + capturedData, + ) + } + + if !bytes.Equal(nonces.Data, []byte{0x01, 0x02}) { + t.Fatalf( + "unexpected nonces data\nexpected: [%x]\nactual: [%x]", + []byte{0x01, 0x02}, + nonces.Data, + ) + } + + if commitment.Identifier != "id-1" { + t.Fatalf( + "unexpected commitment identifier\nexpected: [%v]\nactual: [%v]", + "id-1", + commitment.Identifier, + ) + } + + if !bytes.Equal(commitment.Data, []byte{0x03, 0x04}) { + t.Fatalf( + "unexpected commitment data\nexpected: [%x]\nactual: [%x]", + []byte{0x03, 0x04}, + commitment.Data, + ) + } +} + +func TestUniFFINativeFROSTSigningEngine_SignAndAggregate(t *testing.T) { + expectedErr := errors.New("aggregate error") + + engine, err := newUniFFINativeFROSTSigningEngine(&mockUniFFINativeFROSTBridge{ + generateNoncesAndCommitmentsFn: func( + keyPackageIdentifier string, + keyPackageData []byte, + ) ([]byte, string, []byte, error) { + return nil, "", nil, nil + }, + newSigningPackageFn: func( + message []byte, + commitments []uniFFINativeFROSTCommitment, + ) ([]byte, error) { + return []byte{0x01}, nil + }, + signFn: func( + signingPackageData []byte, + noncesData []byte, + keyPackageIdentifier string, + keyPackageData []byte, + ) (string, []byte, error) { + return "member-1", []byte{0x99}, nil + }, + aggregateFn: func( + signingPackageData []byte, + signatureShares []uniFFINativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, + ) ([]byte, error) { + return nil, expectedErr + }, + }) + if err != nil { + t.Fatalf("unexpected constructor error: [%v]", err) + } + + signingPackage, err := engine.NewSigningPackage( + []byte{0xab}, + []*NativeFROSTCommitment{ + { + Identifier: "member-1", + Data: []byte{0x11}, + }, + }, + ) + if err != nil { + t.Fatalf("unexpected signing package error: [%v]", err) + } + + signatureShare, err := engine.Sign( + signingPackage, + &NativeFROSTNonces{ + Data: []byte{0x22}, + }, + &NativeFROSTKeyPackage{ + Identifier: "member-1", + Data: []byte{0x33}, + }, + ) + if err != nil { + t.Fatalf("unexpected sign error: [%v]", err) + } + + if signatureShare.Identifier != "member-1" { + t.Fatalf( + "unexpected signature share identifier\nexpected: [%v]\nactual: [%v]", + "member-1", + signatureShare.Identifier, + ) + } + + if !bytes.Equal(signatureShare.Data, []byte{0x99}) { + t.Fatalf( + "unexpected signature share data\nexpected: [%x]\nactual: [%x]", + []byte{0x99}, + signatureShare.Data, + ) + } + + _, err = engine.Aggregate( + signingPackage, + []*NativeFROSTSignatureShare{ + signatureShare, + }, + &NativeFROSTPublicKeyPackage{ + VerifyingShares: map[string]string{ + "member-1": "share-1", + }, + VerifyingKey: "pubkey", + }, + ) + if !errors.Is(err, expectedErr) { + t.Fatalf( + "unexpected aggregate error\nexpected: [%v]\nactual: [%v]", + expectedErr, + err, + ) + } +} diff --git a/pkg/frost/signing/native_frost_engine_uniffi_registration_frost_native_default.go b/pkg/frost/signing/native_frost_engine_uniffi_registration_frost_native_default.go new file mode 100644 index 0000000000..f6156db084 --- /dev/null +++ b/pkg/frost/signing/native_frost_engine_uniffi_registration_frost_native_default.go @@ -0,0 +1,7 @@ +//go:build frost_native && !(frost_tbtc_signer && cgo) + +package signing + +func registerBuildTaggedNativeFROSTSigningEngine() error { + return nil +} diff --git a/pkg/frost/signing/native_frost_protocol_frost_native.go b/pkg/frost/signing/native_frost_protocol_frost_native.go new file mode 100644 index 0000000000..d0c08e0a47 --- /dev/null +++ b/pkg/frost/signing/native_frost_protocol_frost_native.go @@ -0,0 +1,813 @@ +//go:build frost_native + +package signing + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const nativeFROSTMessageTypePrefix = "frost_signing/native_frost/" + +var ( + // ErrInvalidSigningAttemptPolicy indicates the provided attempt metadata + // violates coordinator/cohort policy invariants. + ErrInvalidSigningAttemptPolicy = errors.New("invalid signing attempt policy") + // ErrConsumedSigningAttemptReplay indicates signer-side replay protection + // rejected a previously consumed signing attempt payload. + ErrConsumedSigningAttemptReplay = errors.New("consumed signing attempt replay") +) + +type nativeFROSTUniFFIV2SignerMaterial struct { + KeyPackage *NativeFROSTKeyPackage `json:"keyPackage"` + PublicKeyPackage *NativeFROSTPublicKeyPackage `json:"publicKeyPackage"` +} + +func (nufv2sm *nativeFROSTUniFFIV2SignerMaterial) validate() error { + if nufv2sm == nil { + return fmt.Errorf("native signer material payload is nil") + } + + if nufv2sm.KeyPackage == nil { + return fmt.Errorf("native signer key package is nil") + } + + if nufv2sm.KeyPackage.Identifier == "" { + return fmt.Errorf("native signer key package identifier is empty") + } + + if len(nufv2sm.KeyPackage.Data) == 0 { + return fmt.Errorf("native signer key package data is empty") + } + + if nufv2sm.PublicKeyPackage == nil { + return fmt.Errorf("native signer public key package is nil") + } + + if nufv2sm.PublicKeyPackage.VerifyingKey == "" { + return fmt.Errorf("native signer public key package verifying key is empty") + } + + return nil +} + +func decodeNativeFROSTUniFFIV2SignerMaterial( + signerMaterial *NativeSignerMaterial, +) (*nativeFROSTUniFFIV2SignerMaterial, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + if signerMaterial.Format != NativeSignerMaterialFormatFrostUniFFIV2 { + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + signerMaterial.Format, + ) + } + + if len(signerMaterial.Payload) == 0 { + return nil, fmt.Errorf( + "%w: signer material payload is empty", + ErrNativeCryptographyUnavailable, + ) + } + + var decoded nativeFROSTUniFFIV2SignerMaterial + if err := json.Unmarshal(signerMaterial.Payload, &decoded); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal native signer material payload: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if err := decoded.validate(); err != nil { + return nil, fmt.Errorf( + "%w: invalid native signer material payload: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + return &decoded, nil +} + +type nativeFROSTRoundOneCommitmentMessage struct { + SenderIDValue uint32 `json:"senderID"` + SessionIDValue string `json:"sessionID"` + ParticipantIdentifier string `json:"participantIdentifier"` + CommitmentData []byte `json:"commitmentData"` + // AttemptContextHash binds this message to a specific RFC-21 + // AttemptContext. Optional during the Phase 1 migration: an absent + // field is accepted, a present field must be exactly + // AttemptContextHashFieldLength bytes. Higher-level validation + // against the locally-computed context lands in a later RFC-21 + // phase. + AttemptContextHash []byte `json:"attemptContextHash,omitempty"` +} + +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) SenderID() group.MemberIndex { + return group.MemberIndex(nfr1cm.SenderIDValue) +} + +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) SessionID() string { + return nfr1cm.SessionIDValue +} + +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) Type() string { + return nativeFROSTMessageTypePrefix + "round_one_commitment" +} + +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) Marshal() ([]byte, error) { + return json.Marshal(nfr1cm) +} + +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, nfr1cm); err != nil { + return err + } + + if nfr1cm.SenderID() == 0 { + return fmt.Errorf("sender ID is zero") + } + + if nfr1cm.SessionID() == "" { + return fmt.Errorf("session ID is empty") + } + + if nfr1cm.ParticipantIdentifier == "" { + return fmt.Errorf("participant identifier is empty") + } + + if len(nfr1cm.CommitmentData) == 0 { + return fmt.Errorf("commitment data is empty") + } + + if err := validateAttemptContextHashField( + nfr1cm.AttemptContextHash, + ); err != nil { + return err + } + + return nil +} + +// SetAttemptContextHash records the canonical RFC-21 attempt context +// hash on the message. Senders that wish to bind their contribution to +// an attempt context must call this before Marshal; senders that do not +// leave the field absent on the wire. +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + nfr1cm.AttemptContextHash = attemptContextHashFieldFromArray(hash) +} + +// GetAttemptContextHash returns the recorded attempt context hash and a +// presence flag. A receiver that requires the binding should reject +// messages where the flag is false; a receiver that does not yet +// require the binding can ignore the flag without breaking back-compat. +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return attemptContextHashFieldToArray(nfr1cm.AttemptContextHash) +} + +type nativeFROSTRoundTwoSignatureShareMessage struct { + SenderIDValue uint32 `json:"senderID"` + SessionIDValue string `json:"sessionID"` + ParticipantIdentifier string `json:"participantIdentifier"` + SignatureShareData []byte `json:"signatureShareData"` + // AttemptContextHash -- see nativeFROSTRoundOneCommitmentMessage + // for the migration contract. + AttemptContextHash []byte `json:"attemptContextHash,omitempty"` +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) SenderID() group.MemberIndex { + return group.MemberIndex(nfr2ssm.SenderIDValue) +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) SessionID() string { + return nfr2ssm.SessionIDValue +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) Type() string { + return nativeFROSTMessageTypePrefix + "round_two_signature_share" +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) Marshal() ([]byte, error) { + return json.Marshal(nfr2ssm) +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, nfr2ssm); err != nil { + return err + } + + if nfr2ssm.SenderID() == 0 { + return fmt.Errorf("sender ID is zero") + } + + if nfr2ssm.SessionID() == "" { + return fmt.Errorf("session ID is empty") + } + + if nfr2ssm.ParticipantIdentifier == "" { + return fmt.Errorf("participant identifier is empty") + } + + if len(nfr2ssm.SignatureShareData) == 0 { + return fmt.Errorf("signature share data is empty") + } + + if err := validateAttemptContextHashField( + nfr2ssm.AttemptContextHash, + ); err != nil { + return err + } + + return nil +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + nfr2ssm.AttemptContextHash = attemptContextHashFieldFromArray(hash) +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return attemptContextHashFieldToArray(nfr2ssm.AttemptContextHash) +} + +func registerNativeFROSTSigningUnmarshallers(channel net.BroadcastChannel) { + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &nativeFROSTRoundOneCommitmentMessage{} + }) + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &nativeFROSTRoundTwoSignatureShareMessage{} + }) +} + +func executeNativeFROSTSigning( + ctx context.Context, + logger log.StandardLogger, + request *NativeExecutionFFISigningRequest, + engine NativeFROSTSigningEngine, + signerMaterial *nativeFROSTUniFFIV2SignerMaterial, +) (*frost.Signature, error) { + if engine == nil { + return nil, fmt.Errorf( + "%w: native FROST signing engine is unavailable", + ErrNativeCryptographyUnavailable, + ) + } + + if signerMaterial == nil { + return nil, fmt.Errorf( + "%w: native signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + if err := signerMaterial.validate(); err != nil { + return nil, fmt.Errorf( + "%w: invalid native signer material: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + includedMembersSet, includedMembersIndexes, err := includedMembersFromRequest(request) + if err != nil { + return nil, err + } + + if _, ok := includedMembersSet[request.MemberIndex]; !ok { + return nil, fmt.Errorf( + "member [%v] not included in native FROST signing attempt", + request.MemberIndex, + ) + } + + messageBytes := request.Message.Bytes() + if len(messageBytes) == 0 { + messageBytes = []byte{0} + } + + ownNonces, ownCommitment, err := engine.GenerateNoncesAndCommitments( + signerMaterial.KeyPackage, + ) + if err != nil { + return nil, fmt.Errorf( + "native FROST round one generation failed: [%w]", + err, + ) + } + + if ownCommitment == nil { + return nil, fmt.Errorf("native FROST round one returned nil commitment") + } + + if ownCommitment.Identifier == "" { + return nil, fmt.Errorf("native FROST round one commitment identifier is empty") + } + + if len(ownCommitment.Data) == 0 { + return nil, fmt.Errorf("native FROST round one commitment data is empty") + } + + if ownNonces == nil { + return nil, fmt.Errorf("native FROST round one returned nil nonces") + } + + roundOneMessage := &nativeFROSTRoundOneCommitmentMessage{ + SenderIDValue: uint32(request.MemberIndex), + SessionIDValue: request.SessionID, + ParticipantIdentifier: ownCommitment.Identifier, + CommitmentData: append([]byte{}, ownCommitment.Data...), + } + + if err := request.Channel.Send( + ctx, + roundOneMessage, + net.BackoffRetransmissionStrategy, + ); err != nil { + return nil, fmt.Errorf("cannot send native FROST round one message: [%w]", err) + } + + // RFC-21 Phase 4.2/4.3: the recorder comes from the per-process + // roast-retry registry. When the registry is empty (default + // build, or no caller has registered a coordinator), the helper + // returns attempt.NoOpRecorder() and behaviour matches Phase 2. + // When the registry has a coordinator, the helper returns a + // fresh BoundedRecorder so overflow drops at the receive + // callback are captured. The deferred submitSnapshotIfActive + // reads the recorder's Snapshot at end-of-collect and submits + // the result via Coordinator.RecordEvidence. + roundOneRecorder := roastRetryRecorderForCollect() + defer submitSnapshotIfActive(request.SessionID, roundOneRecorder) + roundOneMessages, err := collectNativeFROSTRoundOneMessages( + ctx, + request, + includedMembersSet, + includedMembersIndexes, + roundOneRecorder, + ) + if err != nil { + return nil, err + } + + commitmentsBySender := map[group.MemberIndex]*NativeFROSTCommitment{ + request.MemberIndex: ownCommitment, + } + + for senderID, message := range roundOneMessages { + commitmentsBySender[senderID] = &NativeFROSTCommitment{ + Identifier: message.ParticipantIdentifier, + Data: append([]byte{}, message.CommitmentData...), + } + } + + orderedCommitments := make([]*NativeFROSTCommitment, 0, len(includedMembersIndexes)) + for _, memberIndex := range includedMembersIndexes { + orderedCommitments = append( + orderedCommitments, + commitmentsBySender[memberIndex], + ) + } + + signingPackage, err := engine.NewSigningPackage( + messageBytes, + orderedCommitments, + ) + if err != nil { + return nil, fmt.Errorf( + "native FROST signing package creation failed: [%w]", + err, + ) + } + + if signingPackage == nil { + return nil, fmt.Errorf("native FROST signing package is nil") + } + + ownSignatureShare, err := engine.Sign( + signingPackage, + ownNonces, + signerMaterial.KeyPackage, + ) + if err != nil { + return nil, fmt.Errorf("native FROST round two signing failed: [%w]", err) + } + + if ownSignatureShare == nil { + return nil, fmt.Errorf("native FROST round two returned nil signature share") + } + + if ownSignatureShare.Identifier == "" { + return nil, fmt.Errorf("native FROST signature share identifier is empty") + } + + if len(ownSignatureShare.Data) == 0 { + return nil, fmt.Errorf("native FROST signature share data is empty") + } + + roundTwoMessage := &nativeFROSTRoundTwoSignatureShareMessage{ + SenderIDValue: uint32(request.MemberIndex), + SessionIDValue: request.SessionID, + ParticipantIdentifier: ownSignatureShare.Identifier, + SignatureShareData: append([]byte{}, ownSignatureShare.Data...), + } + + if err := request.Channel.Send( + ctx, + roundTwoMessage, + net.BackoffRetransmissionStrategy, + ); err != nil { + return nil, fmt.Errorf("cannot send native FROST round two message: [%w]", err) + } + + // RFC-21 Phase 4.2/4.3 recorder source + deferred submission -- + // see round-one caller above. + roundTwoRecorder := roastRetryRecorderForCollect() + defer submitSnapshotIfActive(request.SessionID, roundTwoRecorder) + roundTwoMessages, err := collectNativeFROSTRoundTwoMessages( + ctx, + request, + includedMembersSet, + includedMembersIndexes, + roundTwoRecorder, + ) + if err != nil { + return nil, err + } + + signatureSharesBySender := map[group.MemberIndex]*NativeFROSTSignatureShare{ + request.MemberIndex: ownSignatureShare, + } + + for senderID, message := range roundTwoMessages { + signatureSharesBySender[senderID] = &NativeFROSTSignatureShare{ + Identifier: message.ParticipantIdentifier, + Data: append([]byte{}, message.SignatureShareData...), + } + } + + orderedSignatureShares := make([]*NativeFROSTSignatureShare, 0, len(includedMembersIndexes)) + for _, memberIndex := range includedMembersIndexes { + orderedSignatureShares = append( + orderedSignatureShares, + signatureSharesBySender[memberIndex], + ) + } + + signatureBytes, err := engine.Aggregate( + signingPackage, + orderedSignatureShares, + signerMaterial.PublicKeyPackage, + ) + if err != nil { + return nil, fmt.Errorf("native FROST aggregation failed: [%w]", err) + } + + signature := &frost.Signature{} + if err := signature.Unmarshal(signatureBytes); err != nil { + return nil, fmt.Errorf( + "native FROST aggregation returned invalid signature: [%w]", + err, + ) + } + + if logger != nil { + logger.Debugf( + "[member:%v] native FROST signing completed with [%v] participants", + request.MemberIndex, + len(includedMembersIndexes), + ) + } + + return signature, nil +} + +func includedMembersFromRequest( + request *NativeExecutionFFISigningRequest, +) (map[group.MemberIndex]struct{}, []group.MemberIndex, error) { + if request == nil { + return nil, nil, fmt.Errorf("request is nil") + } + + if request.GroupSize <= 0 { + return nil, nil, fmt.Errorf("group size must be positive") + } + + attempt := request.Attempt + if attempt != nil { + if attempt.Number == 0 { + return nil, nil, fmt.Errorf( + "%w: attempt number is zero", + ErrInvalidSigningAttemptPolicy, + ) + } + + if attempt.CoordinatorMemberIndex == 0 { + return nil, nil, fmt.Errorf( + "%w: attempt coordinator member index is zero", + ErrInvalidSigningAttemptPolicy, + ) + } + } + + includedMembersSet := make(map[group.MemberIndex]struct{}) + excludedMembersSet := make(map[group.MemberIndex]struct{}) + + if attempt != nil { + for _, memberIndex := range attempt.ExcludedMembersIndexes { + if memberIndex == 0 { + continue + } + + excludedMembersSet[memberIndex] = struct{}{} + } + } + + if attempt != nil && len(attempt.IncludedMembersIndexes) > 0 { + for _, memberIndex := range attempt.IncludedMembersIndexes { + if memberIndex == 0 { + return nil, nil, fmt.Errorf( + "%w: included member index is zero", + ErrInvalidSigningAttemptPolicy, + ) + } + + if _, excluded := excludedMembersSet[memberIndex]; excluded { + return nil, nil, fmt.Errorf( + "%w: member [%v] is both included and excluded in attempt", + ErrInvalidSigningAttemptPolicy, + memberIndex, + ) + } + + includedMembersSet[memberIndex] = struct{}{} + } + } else { + for i := 1; i <= request.GroupSize; i++ { + memberIndex := group.MemberIndex(i) + if _, excluded := excludedMembersSet[memberIndex]; !excluded { + includedMembersSet[memberIndex] = struct{}{} + } + } + } + + if len(includedMembersSet) == 0 { + if attempt != nil { + return nil, nil, fmt.Errorf( + "%w: included members set is empty", + ErrInvalidSigningAttemptPolicy, + ) + } + + return nil, nil, fmt.Errorf("included members set is empty") + } + + if attempt != nil { + if _, included := includedMembersSet[attempt.CoordinatorMemberIndex]; !included { + return nil, nil, fmt.Errorf( + "%w: attempt coordinator [%v] is not included", + ErrInvalidSigningAttemptPolicy, + attempt.CoordinatorMemberIndex, + ) + } + } + + includedMembersIndexes := make([]group.MemberIndex, 0, len(includedMembersSet)) + for memberIndex := range includedMembersSet { + includedMembersIndexes = append(includedMembersIndexes, memberIndex) + } + + sort.Slice(includedMembersIndexes, func(i, j int) bool { + return includedMembersIndexes[i] < includedMembersIndexes[j] + }) + + return includedMembersSet, includedMembersIndexes, nil +} + +func collectNativeFROSTRoundOneMessages( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, + evidence attempt.EvidenceRecorder, +) (map[group.MemberIndex]*nativeFROSTRoundOneCommitmentMessage, error) { + expectedMessagesCount := len(includedMembersIndexes) - 1 + if expectedMessagesCount <= 0 { + return map[group.MemberIndex]*nativeFROSTRoundOneCommitmentMessage{}, nil + } + + recvCtx, cancelRecvCtx := context.WithCancel(ctx) + defer cancelRecvCtx() + + messageChan := make(chan *nativeFROSTRoundOneCommitmentMessage, expectedMessagesCount*4+1) + + request.Channel.Recv(recvCtx, func(message net.Message) { + payload, ok := message.Payload().(*nativeFROSTRoundOneCommitmentMessage) + if !ok { + return + } + + if !shouldAcceptNativeFROSTMessage( + request, + includedMembersSet, + payload.SenderID(), + payload.SessionID(), + message.SenderPublicKey(), + ) { + evidence.RecordReject(payload.SenderID(), "validation_gate_rejected") + return + } + + if err := verifyMessageAttemptContextHash(payload, request.SessionID); err != nil { + evidence.RecordReject(payload.SenderID(), "attempt_context_hash_mismatch") + return + } + + _ = enqueueOrRecordOverflow(payload, messageChan, evidence) + }) + + receivedMessages := make(map[group.MemberIndex]*nativeFROSTRoundOneCommitmentMessage) + + for len(receivedMessages) < expectedMessagesCount { + select { + case <-ctx.Done(): + return nil, fmt.Errorf( + "native FROST round one collection interrupted: [%w]", + ctx.Err(), + ) + + case message := <-messageChan: + // First-write-wins / equal-or-reject. See the matching comment in + // native_ffi_primitive_transitional_frost_native.go. + senderID := message.SenderID() + if existing, ok := receivedMessages[senderID]; ok { + if !nativeFROSTRoundOneCommitmentMessagesEqual(existing, message) { + evidence.RecordConflict(senderID) + protocolLogger.Warnf( + "dropping conflicting native FROST round one "+ + "commitment from sender [%d]; first-write-wins "+ + "keeps the originally accepted commitment", + senderID, + ) + } + continue + } + receivedMessages[senderID] = message + } + } + + return receivedMessages, nil +} + +func nativeFROSTRoundOneCommitmentMessagesEqual( + left, right *nativeFROSTRoundOneCommitmentMessage, +) bool { + if left == nil || right == nil { + return left == right + } + return left.SenderIDValue == right.SenderIDValue && + left.SessionIDValue == right.SessionIDValue && + left.ParticipantIdentifier == right.ParticipantIdentifier && + bytes.Equal(left.CommitmentData, right.CommitmentData) +} + +func collectNativeFROSTRoundTwoMessages( + ctx context.Context, + request *NativeExecutionFFISigningRequest, + includedMembersSet map[group.MemberIndex]struct{}, + includedMembersIndexes []group.MemberIndex, + evidence attempt.EvidenceRecorder, +) (map[group.MemberIndex]*nativeFROSTRoundTwoSignatureShareMessage, error) { + expectedMessagesCount := len(includedMembersIndexes) - 1 + if expectedMessagesCount <= 0 { + return map[group.MemberIndex]*nativeFROSTRoundTwoSignatureShareMessage{}, nil + } + + recvCtx, cancelRecvCtx := context.WithCancel(ctx) + defer cancelRecvCtx() + + messageChan := make(chan *nativeFROSTRoundTwoSignatureShareMessage, expectedMessagesCount*4+1) + + request.Channel.Recv(recvCtx, func(message net.Message) { + payload, ok := message.Payload().(*nativeFROSTRoundTwoSignatureShareMessage) + if !ok { + return + } + + if !shouldAcceptNativeFROSTMessage( + request, + includedMembersSet, + payload.SenderID(), + payload.SessionID(), + message.SenderPublicKey(), + ) { + evidence.RecordReject(payload.SenderID(), "validation_gate_rejected") + return + } + + if err := verifyMessageAttemptContextHash(payload, request.SessionID); err != nil { + evidence.RecordReject(payload.SenderID(), "attempt_context_hash_mismatch") + return + } + + _ = enqueueOrRecordOverflow(payload, messageChan, evidence) + }) + + receivedMessages := make(map[group.MemberIndex]*nativeFROSTRoundTwoSignatureShareMessage) + + for len(receivedMessages) < expectedMessagesCount { + select { + case <-ctx.Done(): + return nil, fmt.Errorf( + "native FROST round two collection interrupted: [%w]", + ctx.Err(), + ) + + case message := <-messageChan: + // First-write-wins / equal-or-reject. See round one above. + senderID := message.SenderID() + if existing, ok := receivedMessages[senderID]; ok { + if !nativeFROSTRoundTwoSignatureShareMessagesEqual( + existing, + message, + ) { + evidence.RecordConflict(senderID) + protocolLogger.Warnf( + "dropping conflicting native FROST round two "+ + "signature share from sender [%d]; first-write-wins "+ + "keeps the originally accepted share", + senderID, + ) + } + continue + } + receivedMessages[senderID] = message + } + } + + return receivedMessages, nil +} + +func nativeFROSTRoundTwoSignatureShareMessagesEqual( + left, right *nativeFROSTRoundTwoSignatureShareMessage, +) bool { + if left == nil || right == nil { + return left == right + } + return left.SenderIDValue == right.SenderIDValue && + left.SessionIDValue == right.SessionIDValue && + left.ParticipantIdentifier == right.ParticipantIdentifier && + bytes.Equal(left.SignatureShareData, right.SignatureShareData) +} + +func shouldAcceptNativeFROSTMessage( + request *NativeExecutionFFISigningRequest, + includedMembersSet map[group.MemberIndex]struct{}, + senderID group.MemberIndex, + sessionID string, + senderPublicKey []byte, +) bool { + if senderID == 0 { + return false + } + + if senderID == request.MemberIndex { + return false + } + + if sessionID != request.SessionID { + return false + } + + if _, included := includedMembersSet[senderID]; !included { + return false + } + + if request.MembershipValidator == nil { + return true + } + + return request.MembershipValidator.IsValidMembership(senderID, senderPublicKey) +} diff --git a/pkg/frost/signing/native_frost_protocol_frost_native_test.go b/pkg/frost/signing/native_frost_protocol_frost_native_test.go new file mode 100644 index 0000000000..48c0ecab54 --- /dev/null +++ b/pkg/frost/signing/native_frost_protocol_frost_native_test.go @@ -0,0 +1,617 @@ +//go:build frost_native + +package signing + +import ( + "context" + "crypto/sha256" + "crypto/sha512" + "encoding/json" + "errors" + "fmt" + "math/big" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type deterministicNativeFROSTSigningEngine struct{} + +func (dnfse *deterministicNativeFROSTSigningEngine) GenerateNoncesAndCommitments( + keyPackage *NativeFROSTKeyPackage, +) (*NativeFROSTNonces, *NativeFROSTCommitment, error) { + if keyPackage == nil { + return nil, nil, fmt.Errorf("key package is nil") + } + + if keyPackage.Identifier == "" { + return nil, nil, fmt.Errorf("key package identifier is empty") + } + + nonceSeed := sha256.Sum256( + append( + []byte("nonce:"), + []byte(keyPackage.Identifier)..., + ), + ) + commitmentSeed := sha256.Sum256( + append( + []byte("commitment:"), + []byte(keyPackage.Identifier)..., + ), + ) + + return &NativeFROSTNonces{ + Data: nonceSeed[:], + }, &NativeFROSTCommitment{ + Identifier: keyPackage.Identifier, + Data: commitmentSeed[:], + }, nil +} + +func (dnfse *deterministicNativeFROSTSigningEngine) NewSigningPackage( + message []byte, + commitments []*NativeFROSTCommitment, +) (*NativeFROSTSigningPackage, error) { + if len(commitments) == 0 { + return nil, fmt.Errorf("commitments are empty") + } + + serialized := append([]byte{}, message...) + for _, commitment := range commitments { + if commitment == nil { + return nil, fmt.Errorf("commitment is nil") + } + + serialized = append(serialized, []byte(commitment.Identifier)...) + serialized = append(serialized, commitment.Data...) + } + + packageDigest := sha256.Sum256(serialized) + + return &NativeFROSTSigningPackage{ + Data: packageDigest[:], + }, nil +} + +func (dnfse *deterministicNativeFROSTSigningEngine) Sign( + signingPackage *NativeFROSTSigningPackage, + nonces *NativeFROSTNonces, + keyPackage *NativeFROSTKeyPackage, +) (*NativeFROSTSignatureShare, error) { + if signingPackage == nil { + return nil, fmt.Errorf("signing package is nil") + } + + if nonces == nil { + return nil, fmt.Errorf("nonces are nil") + } + + if keyPackage == nil { + return nil, fmt.Errorf("key package is nil") + } + + serialized := append([]byte{}, signingPackage.Data...) + serialized = append(serialized, nonces.Data...) + serialized = append(serialized, []byte(keyPackage.Identifier)...) + serialized = append(serialized, keyPackage.Data...) + + shareDigest := sha256.Sum256(serialized) + + return &NativeFROSTSignatureShare{ + Identifier: keyPackage.Identifier, + Data: shareDigest[:], + }, nil +} + +func (dnfse *deterministicNativeFROSTSigningEngine) Aggregate( + signingPackage *NativeFROSTSigningPackage, + signatureShares []*NativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) ([]byte, error) { + if signingPackage == nil { + return nil, fmt.Errorf("signing package is nil") + } + + if publicKeyPackage == nil { + return nil, fmt.Errorf("public key package is nil") + } + + if len(signatureShares) == 0 { + return nil, fmt.Errorf("signature shares are empty") + } + + orderedSignatureShares := append([]*NativeFROSTSignatureShare{}, signatureShares...) + sort.Slice(orderedSignatureShares, func(i, j int) bool { + return orderedSignatureShares[i].Identifier < orderedSignatureShares[j].Identifier + }) + + serialized := append([]byte{}, signingPackage.Data...) + for _, signatureShare := range orderedSignatureShares { + if signatureShare == nil { + return nil, fmt.Errorf("signature share is nil") + } + + serialized = append(serialized, []byte(signatureShare.Identifier)...) + serialized = append(serialized, signatureShare.Data...) + } + + serialized = append(serialized, []byte(publicKeyPackage.VerifyingKey)...) + + signatureDigest := sha512.Sum512(serialized) + + return signatureDigest[:], nil +} + +type recordingNativeFROSTSigningEngine struct { + deterministicNativeFROSTSigningEngine + mutex sync.Mutex + commitmentIDSnapshots [][]string + signatureShareIDSnapshots [][]string +} + +func (rnfse *recordingNativeFROSTSigningEngine) NewSigningPackage( + message []byte, + commitments []*NativeFROSTCommitment, +) (*NativeFROSTSigningPackage, error) { + commitmentIDs := make([]string, 0, len(commitments)) + for _, commitment := range commitments { + if commitment == nil { + commitmentIDs = append(commitmentIDs, "") + continue + } + + commitmentIDs = append(commitmentIDs, commitment.Identifier) + } + + rnfse.mutex.Lock() + rnfse.commitmentIDSnapshots = append( + rnfse.commitmentIDSnapshots, + append([]string{}, commitmentIDs...), + ) + rnfse.mutex.Unlock() + + return rnfse.deterministicNativeFROSTSigningEngine.NewSigningPackage( + message, + commitments, + ) +} + +func (rnfse *recordingNativeFROSTSigningEngine) Aggregate( + signingPackage *NativeFROSTSigningPackage, + signatureShares []*NativeFROSTSignatureShare, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) ([]byte, error) { + signatureShareIDs := make([]string, 0, len(signatureShares)) + for _, signatureShare := range signatureShares { + if signatureShare == nil { + signatureShareIDs = append(signatureShareIDs, "") + continue + } + + signatureShareIDs = append(signatureShareIDs, signatureShare.Identifier) + } + + rnfse.mutex.Lock() + rnfse.signatureShareIDSnapshots = append( + rnfse.signatureShareIDSnapshots, + append([]string{}, signatureShareIDs...), + ) + rnfse.mutex.Unlock() + + return rnfse.deterministicNativeFROSTSigningEngine.Aggregate( + signingPackage, + signatureShares, + publicKeyPackage, + ) +} + +func (rnfse *recordingNativeFROSTSigningEngine) commitmentIDs() [][]string { + rnfse.mutex.Lock() + defer rnfse.mutex.Unlock() + + snapshots := make([][]string, 0, len(rnfse.commitmentIDSnapshots)) + for _, snapshot := range rnfse.commitmentIDSnapshots { + snapshots = append(snapshots, append([]string{}, snapshot...)) + } + + return snapshots +} + +func (rnfse *recordingNativeFROSTSigningEngine) signatureShareIDs() [][]string { + rnfse.mutex.Lock() + defer rnfse.mutex.Unlock() + + snapshots := make([][]string, 0, len(rnfse.signatureShareIDSnapshots)) + for _, snapshot := range rnfse.signatureShareIDSnapshots { + snapshots = append(snapshots, append([]string{}, snapshot...)) + } + + return snapshots +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_NativeFROSTPath( + t *testing.T, +) { + RegisterNativeFROSTSigningEngine(&deterministicNativeFROSTSigningEngine{}) + t.Cleanup(UnregisterNativeFROSTSigningEngine) + + provider := local.Connect() + channel, err := provider.BroadcastChannelFor("native-frost-signing-protocol-test") + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + participantCount := 3 + includedMembers := []group.MemberIndex{1, 2, 3} + + requests := make([]*NativeExecutionFFISigningRequest, participantCount) + for i := 0; i < participantCount; i++ { + memberIndex := group.MemberIndex(i + 1) + requests[i], err = newNativeFROSTSigningRequestForTest( + memberIndex, + includedMembers, + channel, + participantCount, + ) + if err != nil { + t.Fatalf("failed preparing request for member [%v]: [%v]", memberIndex, err) + } + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + results := make([]*frostSignatureResultForTest, participantCount) + wg := sync.WaitGroup{} + wg.Add(participantCount) + + for i := 0; i < participantCount; i++ { + go func(index int) { + defer wg.Done() + + signature, signErr := primitive.Sign(ctx, nil, requests[index]) + results[index] = &frostSignatureResultForTest{ + signature: signature, + err: signErr, + } + }(i) + } + + wg.Wait() + + for i, result := range results { + if result == nil { + t.Fatalf("missing result for member [%v]", i+1) + } + + if result.err != nil { + t.Fatalf( + "unexpected signing error for member [%v]: [%v]", + i+1, + result.err, + ) + } + + if result.signature == nil { + t.Fatalf("nil signature for member [%v]", i+1) + } + } + + for i := 1; i < participantCount; i++ { + if !results[0].signature.Equals(results[i].signature) { + t.Fatalf( + "signature mismatch\nfirst: [%v]\nsecond: [%v]", + results[0].signature, + results[i].signature, + ) + } + } +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_NativeFROSTPath_AttemptVariationUsesCohortSelections( + t *testing.T, +) { + engine := &recordingNativeFROSTSigningEngine{} + RegisterNativeFROSTSigningEngine(engine) + t.Cleanup(UnregisterNativeFROSTSigningEngine) + + provider := local.Connect() + channel, err := provider.BroadcastChannelFor("native-frost-signing-protocol-attempt-variation-test") + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + runRound := func( + sessionID string, + includedMembers []group.MemberIndex, + groupSize int, + ) []*frost.Signature { + requests := make([]*NativeExecutionFFISigningRequest, len(includedMembers)) + for i := 0; i < len(includedMembers); i++ { + memberIndex := includedMembers[i] + + request, roundErr := newNativeFROSTSigningRequestWithSessionForTest( + memberIndex, + includedMembers, + channel, + groupSize, + sessionID, + ) + if roundErr != nil { + t.Fatalf( + "failed preparing request for member [%v] in session [%s]: [%v]", + memberIndex, + sessionID, + roundErr, + ) + } + + requests[i] = request + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCtx() + + results := make([]*frostSignatureResultForTest, len(includedMembers)) + var wg sync.WaitGroup + wg.Add(len(includedMembers)) + + for i := 0; i < len(includedMembers); i++ { + go func(index int) { + defer wg.Done() + + signature, signErr := primitive.Sign(ctx, nil, requests[index]) + results[index] = &frostSignatureResultForTest{ + signature: signature, + err: signErr, + } + }(i) + } + + wg.Wait() + + signatures := make([]*frost.Signature, len(includedMembers)) + for i := 0; i < len(includedMembers); i++ { + if results[i] == nil { + t.Fatalf( + "missing signing result for member [%v] in session [%s]", + includedMembers[i], + sessionID, + ) + } + + if results[i].err != nil { + t.Fatalf( + "unexpected signing error for member [%v] in session [%s]: [%v]", + includedMembers[i], + sessionID, + results[i].err, + ) + } + + if results[i].signature == nil { + t.Fatalf( + "nil signature for member [%v] in session [%s]", + includedMembers[i], + sessionID, + ) + } + + signatures[i] = results[i].signature + } + + return signatures + } + + assertSignaturesMatch := func( + sessionID string, + signatures []*frost.Signature, + ) { + if len(signatures) == 0 { + t.Fatalf("no signatures for session [%s]", sessionID) + } + + for i := 1; i < len(signatures); i++ { + if !signatures[0].Equals(signatures[i]) { + t.Fatalf( + "signature mismatch in session [%s]\nfirst: [%v]\nsecond: [%v]", + sessionID, + signatures[0], + signatures[i], + ) + } + } + } + + roundOneSignatures := runRound( + "native-frost-signing-session-attempt-1", + []group.MemberIndex{1, 2, 3}, + 3, + ) + assertSignaturesMatch("native-frost-signing-session-attempt-1", roundOneSignatures) + + roundTwoSignatures := runRound( + "native-frost-signing-session-attempt-2", + []group.MemberIndex{1, 3}, + 3, + ) + assertSignaturesMatch("native-frost-signing-session-attempt-2", roundTwoSignatures) + + snapshotHistogram := func(snapshots [][]string) map[string]int { + histogram := make(map[string]int) + for _, snapshot := range snapshots { + histogram[strings.Join(snapshot, ",")]++ + } + + return histogram + } + + expectedHistogram := map[string]int{ + "member-1,member-2,member-3": 3, + "member-1,member-3": 2, + } + + assertHistogram := func(name string, actual map[string]int) { + if len(actual) != len(expectedHistogram) { + t.Fatalf( + "unexpected %s histogram size\nexpected: [%v]\nactual: [%v]", + name, + len(expectedHistogram), + len(actual), + ) + } + + for key, expectedCount := range expectedHistogram { + actualCount, ok := actual[key] + if !ok { + t.Fatalf("missing %s histogram key: [%s]", name, key) + } + + if actualCount != expectedCount { + t.Fatalf( + "unexpected %s count for key [%s]\nexpected: [%v]\nactual: [%v]", + name, + key, + expectedCount, + actualCount, + ) + } + } + } + + assertHistogram( + "commitment IDs", + snapshotHistogram(engine.commitmentIDs()), + ) + assertHistogram( + "signature share IDs", + snapshotHistogram(engine.signatureShareIDs()), + ) +} + +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_NativeFROSTPathWithoutEngine( + t *testing.T, +) { + UnregisterNativeFROSTSigningEngine() + t.Cleanup(UnregisterNativeFROSTSigningEngine) + + provider := local.Connect() + channel, err := provider.BroadcastChannelFor("native-frost-signing-protocol-unavailable-test") + if err != nil { + t.Fatalf("failed creating broadcast channel: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + primitive.RegisterUnmarshallers(channel) + + request, err := newNativeFROSTSigningRequestForTest( + 1, + []group.MemberIndex{1}, + channel, + 1, + ) + if err != nil { + t.Fatalf("failed creating native request: [%v]", err) + } + + _, err = primitive.Sign(context.Background(), nil, request) + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } +} + +type frostSignatureResultForTest struct { + signature *frost.Signature + err error +} + +func newNativeFROSTSigningRequestForTest( + memberIndex group.MemberIndex, + includedMembers []group.MemberIndex, + channel net.BroadcastChannel, + groupSize int, +) (*NativeExecutionFFISigningRequest, error) { + return newNativeFROSTSigningRequestWithSessionForTest( + memberIndex, + includedMembers, + channel, + groupSize, + "native-frost-signing-session", + ) +} + +func newNativeFROSTSigningRequestWithSessionForTest( + memberIndex group.MemberIndex, + includedMembers []group.MemberIndex, + channel net.BroadcastChannel, + groupSize int, + sessionID string, +) (*NativeExecutionFFISigningRequest, error) { + keyPackage := &NativeFROSTKeyPackage{ + Identifier: fmt.Sprintf("member-%v", memberIndex), + Data: []byte{ + byte(memberIndex), + 0x01, + }, + } + + verifyingShares := make(map[string]string) + for i := 1; i <= groupSize; i++ { + verifyingShares[fmt.Sprintf("member-%v", i)] = fmt.Sprintf("share-%v", i) + } + + payload, err := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: keyPackage, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingShares: verifyingShares, + VerifyingKey: "verifying-key", + }, + }) + if err != nil { + return nil, err + } + + return &NativeExecutionFFISigningRequest{ + Message: bigOneForTest(), + SessionID: sessionID, + MemberIndex: memberIndex, + GroupSize: groupSize, + DishonestThreshold: 1, + Channel: channel, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: includedMembers[0], + IncludedMembersIndexes: append([]group.MemberIndex{}, includedMembers...), + }, + }, nil +} + +func bigOneForTest() *big.Int { + return big.NewInt(1) +} diff --git a/pkg/frost/signing/native_signer_material.go b/pkg/frost/signing/native_signer_material.go new file mode 100644 index 0000000000..af7b84e74f --- /dev/null +++ b/pkg/frost/signing/native_signer_material.go @@ -0,0 +1,90 @@ +package signing + +import "fmt" + +const ( + // NativeSignerMaterialFormatFrostUniFFIV1 is the canonical format name for + // serialized signer material expected by UniFFI-based native FROST bridges. + NativeSignerMaterialFormatFrostUniFFIV1 = "frost-uniffi-v1" +) + +// NativeSignerMaterial carries backend-native signer material required by +// native FROST execution paths. +type NativeSignerMaterial struct { + Format string + Payload []byte +} + +func (nsm *NativeSignerMaterial) clone() *NativeSignerMaterial { + if nsm == nil { + return nil + } + + result := &NativeSignerMaterial{ + Format: nsm.Format, + } + + if len(nsm.Payload) > 0 { + result.Payload = append([]byte{}, nsm.Payload...) + } + + return result +} + +func (nsm *NativeSignerMaterial) validate() error { + if nsm == nil { + return fmt.Errorf("native signer material is nil") + } + + if nsm.Format == "" { + return fmt.Errorf("native signer material format is empty") + } + + if len(nsm.Payload) == 0 { + return fmt.Errorf("native signer material payload is empty") + } + + return nil +} + +// NativeSignerMaterial resolves native signer material required by +// FFI-backed native execution. +// +// Supported Request.SignerMaterial forms: +// - *NativeSignerMaterial +// - NativeSignerMaterial +// - []byte (interpreted as NativeSignerMaterialFormatFrostUniFFIV1 payload) +func (r *Request) NativeSignerMaterial() (*NativeSignerMaterial, error) { + if r == nil { + return nil, fmt.Errorf("request is nil") + } + + if r.SignerMaterial == nil { + return nil, fmt.Errorf("native signer material is nil") + } + + var nativeSignerMaterial *NativeSignerMaterial + + switch signerMaterial := r.SignerMaterial.(type) { + case *NativeSignerMaterial: + nativeSignerMaterial = signerMaterial.clone() + case NativeSignerMaterial: + nativeSignerMaterial = signerMaterial.clone() + case []byte: + nativeSignerMaterial = &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: append([]byte{}, signerMaterial...), + } + default: + return nil, fmt.Errorf( + "native signer material has wrong type: [%T]", + r.SignerMaterial, + ) + } + + if err := nativeSignerMaterial.validate(); err != nil { + return nil, err + } + + return nativeSignerMaterial, nil +} diff --git a/pkg/frost/signing/native_signer_material_test.go b/pkg/frost/signing/native_signer_material_test.go new file mode 100644 index 0000000000..c3b92ffd08 --- /dev/null +++ b/pkg/frost/signing/native_signer_material_test.go @@ -0,0 +1,155 @@ +package signing + +import ( + "bytes" + "strings" + "testing" +) + +func TestRequest_NativeSignerMaterial_FromPointer(t *testing.T) { + input := &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x01, 0x02, 0x03}, + } + + request := &Request{ + SignerMaterial: input, + } + + result, err := request.NativeSignerMaterial() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if result == input { + t.Fatal("expected a clone of native signer material") + } + + if result.Format != input.Format { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + input.Format, + result.Format, + ) + } + + if !bytes.Equal(result.Payload, input.Payload) { + t.Fatalf( + "unexpected signer material payload\nexpected: [%x]\nactual: [%x]", + input.Payload, + result.Payload, + ) + } +} + +func TestRequest_NativeSignerMaterial_FromValue(t *testing.T) { + request := &Request{ + SignerMaterial: NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0xaa, 0xbb}, + }, + } + + result, err := request.NativeSignerMaterial() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if result.Format != NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + NativeSignerMaterialFormatFrostUniFFIV1, + result.Format, + ) + } +} + +func TestRequest_NativeSignerMaterial_FromBytesUsesDefaultFormat(t *testing.T) { + request := &Request{ + SignerMaterial: []byte{0x10, 0x20}, + } + + result, err := request.NativeSignerMaterial() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if result.Format != NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + NativeSignerMaterialFormatFrostUniFFIV1, + result.Format, + ) + } +} + +func TestRequest_NativeSignerMaterial_NilRequest(t *testing.T) { + _, err := (*Request)(nil).NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestRequest_NativeSignerMaterial_NilMaterial(t *testing.T) { + _, err := (&Request{}).NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native signer material is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material is nil", + err, + ) + } +} + +func TestRequest_NativeSignerMaterial_WrongType(t *testing.T) { + request := &Request{ + SignerMaterial: "invalid", + } + + _, err := request.NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native signer material has wrong type") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material has wrong type", + err, + ) + } +} + +func TestRequest_NativeSignerMaterial_ValidationFailure(t *testing.T) { + request := &Request{ + SignerMaterial: NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{}, + }, + } + + _, err := request.NativeSignerMaterial() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "native signer material payload is empty") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "native signer material payload is empty", + err, + ) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_build_taproot_tx_frost_native.go b/pkg/frost/signing/native_tbtc_signer_build_taproot_tx_frost_native.go new file mode 100644 index 0000000000..e20207b8e3 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_build_taproot_tx_frost_native.go @@ -0,0 +1,36 @@ +//go:build frost_native + +package signing + +import "fmt" + +// BuildNativeTBTCSignerTaprootTx routes a BuildTaprootTx request through the +// currently-registered coarse tbtc-signer engine. +func BuildNativeTBTCSignerTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + if sessionID == "" { + return nil, fmt.Errorf("session ID is empty") + } + + if len(inputs) == 0 { + return nil, fmt.Errorf("inputs are empty") + } + + if len(outputs) == 0 { + return nil, fmt.Errorf("outputs are empty") + } + + nativeEngine := currentNativeTBTCSignerEngine() + if nativeEngine == nil { + return nil, fmt.Errorf( + "%w: native tbtc-signer engine is unavailable", + ErrNativeCryptographyUnavailable, + ) + } + + return nativeEngine.BuildTaprootTx(sessionID, inputs, outputs, scriptTreeHex) +} diff --git a/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry.go b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry.go new file mode 100644 index 0000000000..d406baed0c --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry.go @@ -0,0 +1,72 @@ +package signing + +import ( + "fmt" + "sync" +) + +// NativeTBTCSignerCoarseSignatureEvent describes successful coarse-path +// signature production for tbtc-signer payloads. +type NativeTBTCSignerCoarseSignatureEvent struct { + SessionID string + KeyGroupSource string + EngineVersion string +} + +// NativeTBTCSignerCoarseSignatureObserver consumes coarse-signature telemetry +// events. +type NativeTBTCSignerCoarseSignatureObserver func( + event NativeTBTCSignerCoarseSignatureEvent, +) + +var ( + nativeTBTCSignerCoarseSignatureObserverMutex sync.RWMutex + nativeTBTCSignerCoarseSignatureObserver NativeTBTCSignerCoarseSignatureObserver +) + +// RegisterNativeTBTCSignerCoarseSignatureObserver registers a process-wide +// observer used to report tbtc-signer coarse-signature success events. +// Only a single observer is supported. +func RegisterNativeTBTCSignerCoarseSignatureObserver( + observer NativeTBTCSignerCoarseSignatureObserver, +) error { + if observer == nil { + return fmt.Errorf("native tbtc-signer coarse signature observer is nil") + } + + nativeTBTCSignerCoarseSignatureObserverMutex.Lock() + defer nativeTBTCSignerCoarseSignatureObserverMutex.Unlock() + + if nativeTBTCSignerCoarseSignatureObserver != nil { + return fmt.Errorf( + "native tbtc-signer coarse signature observer is already registered", + ) + } + + nativeTBTCSignerCoarseSignatureObserver = observer + + return nil +} + +// UnregisterNativeTBTCSignerCoarseSignatureObserver clears coarse-signature +// observer registration. +func UnregisterNativeTBTCSignerCoarseSignatureObserver() { + nativeTBTCSignerCoarseSignatureObserverMutex.Lock() + defer nativeTBTCSignerCoarseSignatureObserverMutex.Unlock() + + nativeTBTCSignerCoarseSignatureObserver = nil +} + +func emitNativeTBTCSignerCoarseSignatureEvent( + event NativeTBTCSignerCoarseSignatureEvent, +) { + nativeTBTCSignerCoarseSignatureObserverMutex.RLock() + observer := nativeTBTCSignerCoarseSignatureObserver + nativeTBTCSignerCoarseSignatureObserverMutex.RUnlock() + + if observer == nil { + return + } + + observer(event) +} diff --git a/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry_test.go b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry_test.go new file mode 100644 index 0000000000..5c59d3a020 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_coarse_signature_telemetry_test.go @@ -0,0 +1,85 @@ +package signing + +import "testing" + +func TestRegisterNativeTBTCSignerCoarseSignatureObserverRejectsNil(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + err := RegisterNativeTBTCSignerCoarseSignatureObserver(nil) + if err == nil { + t.Fatal("expected registration error") + } +} + +func TestRegisterNativeTBTCSignerCoarseSignatureObserverRejectsDuplicate(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + firstErr := RegisterNativeTBTCSignerCoarseSignatureObserver( + func(NativeTBTCSignerCoarseSignatureEvent) {}, + ) + if firstErr != nil { + t.Fatalf("unexpected first registration error: [%v]", firstErr) + } + + secondErr := RegisterNativeTBTCSignerCoarseSignatureObserver( + func(NativeTBTCSignerCoarseSignatureEvent) {}, + ) + if secondErr == nil { + t.Fatal("expected duplicate registration error") + } +} + +func TestEmitNativeTBTCSignerCoarseSignatureEvent(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + var ( + received bool + actual NativeTBTCSignerCoarseSignatureEvent + ) + + err := RegisterNativeTBTCSignerCoarseSignatureObserver( + func(event NativeTBTCSignerCoarseSignatureEvent) { + received = true + actual = event + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + expected := NativeTBTCSignerCoarseSignatureEvent{ + SessionID: "session-1", + KeyGroupSource: "legacy-wallet-pubkey", + EngineVersion: "tbtc-signer/0.1.0-bootstrap", + } + + emitNativeTBTCSignerCoarseSignatureEvent(expected) + + if !received { + t.Fatal("expected coarse signature event to be delivered") + } + + if actual != expected { + t.Fatalf( + "unexpected coarse signature event\nexpected: [%+v]\nactual: [%+v]", + expected, + actual, + ) + } +} + +func TestEmitNativeTBTCSignerCoarseSignatureEventWithoutObserver(t *testing.T) { + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + emitNativeTBTCSignerCoarseSignatureEvent( + NativeTBTCSignerCoarseSignatureEvent{ + SessionID: "session-1", + KeyGroupSource: "legacy-wallet-pubkey", + EngineVersion: "tbtc-signer/0.1.0-bootstrap", + }, + ) +} diff --git a/pkg/frost/signing/native_tbtc_signer_engine_frost_native.go b/pkg/frost/signing/native_tbtc_signer_engine_frost_native.go new file mode 100644 index 0000000000..b19c88bf63 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_engine_frost_native.go @@ -0,0 +1,121 @@ +//go:build frost_native + +package signing + +import "fmt" + +// NativeTBTCSignerDKGParticipant identifies a DKG participant for coarse +// tbtc-signer RunDKG operation. +type NativeTBTCSignerDKGParticipant struct { + Identifier uint16 `json:"identifier"` + PublicKeyHex string `json:"publicKeyHex"` +} + +// NativeTBTCSignerDKGResult captures DKG result metadata returned by RunDKG. +type NativeTBTCSignerDKGResult struct { + SessionID string `json:"sessionID"` + KeyGroup string `json:"keyGroup"` + ParticipantCount uint16 `json:"participantCount"` + Threshold uint16 `json:"threshold"` + CreatedAtUnix uint64 `json:"createdAtUnix"` +} + +// NativeTBTCSignerRoundContribution is a participant contribution consumed by +// tbtc-signer during signature finalization. +type NativeTBTCSignerRoundContribution struct { + Identifier uint16 `json:"identifier"` + Data []byte `json:"data"` +} + +// NativeTBTCSignerTxInput describes an unsigned transaction input consumed by +// BuildTaprootTx. +type NativeTBTCSignerTxInput struct { + TxIDHex string `json:"txIDHex"` + Vout uint32 `json:"vout"` + ValueSats uint64 `json:"valueSats"` +} + +// NativeTBTCSignerTxOutput describes an unsigned transaction output consumed +// by BuildTaprootTx. +type NativeTBTCSignerTxOutput struct { + ScriptPubKeyHex string `json:"scriptPubKeyHex"` + ValueSats uint64 `json:"valueSats"` +} + +// NativeTBTCSignerTxResult captures unsigned transaction metadata returned by +// BuildTaprootTx. +type NativeTBTCSignerTxResult struct { + SessionID string `json:"sessionID"` + TxHex string `json:"txHex"` +} + +// NativeTBTCSignerRoundState captures coarse session round metadata returned by +// StartSignRound. +type NativeTBTCSignerRoundState struct { + SessionID string `json:"sessionID"` + RoundID string `json:"roundID"` + RequiredContributions uint16 `json:"requiredContributions"` + MessageDigestHex string `json:"messageDigestHex"` + SigningParticipants []uint16 `json:"signingParticipants"` + OwnContribution *NativeTBTCSignerRoundContribution `json:"ownContribution"` +} + +// NativeTBTCSignerEngine executes coarse, session-keyed tbtc-signer +// operations. +type NativeTBTCSignerEngine interface { + RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, + ) (*NativeTBTCSignerDKGResult, error) + StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, + ) (*NativeTBTCSignerRoundState, error) + FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, + ) ([]byte, error) + BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, + ) (*NativeTBTCSignerTxResult, error) +} + +var nativeTBTCSignerEngine NativeTBTCSignerEngine + +// RegisterNativeTBTCSignerEngine registers the coarse tbtc-signer engine used +// by frost_tbtc_signer builds. +func RegisterNativeTBTCSignerEngine(engine NativeTBTCSignerEngine) error { + if engine == nil { + return fmt.Errorf("native tbtc-signer engine is nil") + } + + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeTBTCSignerEngine = engine + + return nil +} + +// UnregisterNativeTBTCSignerEngine clears coarse tbtc-signer engine +// registration. +func UnregisterNativeTBTCSignerEngine() { + executionBackendMutex.Lock() + defer executionBackendMutex.Unlock() + + nativeTBTCSignerEngine = nil +} + +func currentNativeTBTCSignerEngine() NativeTBTCSignerEngine { + executionBackendMutex.RLock() + defer executionBackendMutex.RUnlock() + + return nativeTBTCSignerEngine +} diff --git a/pkg/frost/signing/native_tbtc_signer_engine_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_engine_frost_native_test.go new file mode 100644 index 0000000000..a0487c6f75 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_engine_frost_native_test.go @@ -0,0 +1,87 @@ +//go:build frost_native + +package signing + +import ( + "fmt" + "testing" +) + +type mockNativeTBTCSignerEngine struct{} + +func (mntse *mockNativeTBTCSignerEngine) RunDKG( + sessionID string, + participants []NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*NativeTBTCSignerDKGResult, error) { + return nil, fmt.Errorf("not implemented") +} + +func (mntse *mockNativeTBTCSignerEngine) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, +) (*NativeTBTCSignerRoundState, error) { + _ = memberIdentifier + _ = signingParticipants + return nil, fmt.Errorf("not implemented") +} + +func (mntse *mockNativeTBTCSignerEngine) FinalizeSignRound( + sessionID string, + roundContributions []NativeTBTCSignerRoundContribution, +) ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +func (mntse *mockNativeTBTCSignerEngine) BuildTaprootTx( + sessionID string, + inputs []NativeTBTCSignerTxInput, + outputs []NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*NativeTBTCSignerTxResult, error) { + return nil, fmt.Errorf("not implemented") +} + +func TestRegisterNativeTBTCSignerEngineRejectsNil(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + err := RegisterNativeTBTCSignerEngine(nil) + if err == nil { + t.Fatal("expected registration error") + } +} + +func TestRegisterNativeTBTCSignerEngine(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + + engine := &mockNativeTBTCSignerEngine{} + + err := RegisterNativeTBTCSignerEngine(engine) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + if currentNativeTBTCSignerEngine() != engine { + t.Fatal("expected current native tbtc-signer engine to match registered engine") + } +} + +func TestUnregisterNativeTBTCSignerEngine(t *testing.T) { + UnregisterNativeTBTCSignerEngine() + + err := RegisterNativeTBTCSignerEngine(&mockNativeTBTCSignerEngine{}) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + UnregisterNativeTBTCSignerEngine() + + if currentNativeTBTCSignerEngine() != nil { + t.Fatal("expected native tbtc-signer engine to be nil after unregister") + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_fallback_telemetry.go b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry.go new file mode 100644 index 0000000000..82a1469ffa --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry.go @@ -0,0 +1,66 @@ +package signing + +import ( + "fmt" + "sync" +) + +// NativeTBTCSignerFallbackEvent describes a single fallback from the +// tbtc-signer coarse path to the legacy signing path. +type NativeTBTCSignerFallbackEvent struct { + SessionID string + Reason string + KeyGroupSource string + LegacyPrivateKeyShareExists bool +} + +// NativeTBTCSignerFallbackObserver consumes fallback telemetry events. +type NativeTBTCSignerFallbackObserver func(event NativeTBTCSignerFallbackEvent) + +var ( + nativeTBTCSignerFallbackObserverMutex sync.RWMutex + nativeTBTCSignerFallbackObserver NativeTBTCSignerFallbackObserver +) + +// RegisterNativeTBTCSignerFallbackObserver registers a process-wide observer +// used to report tbtc-signer fallback events. +// Only a single observer is supported. +func RegisterNativeTBTCSignerFallbackObserver( + observer NativeTBTCSignerFallbackObserver, +) error { + if observer == nil { + return fmt.Errorf("native tbtc-signer fallback observer is nil") + } + + nativeTBTCSignerFallbackObserverMutex.Lock() + defer nativeTBTCSignerFallbackObserverMutex.Unlock() + + if nativeTBTCSignerFallbackObserver != nil { + return fmt.Errorf("native tbtc-signer fallback observer is already registered") + } + + nativeTBTCSignerFallbackObserver = observer + + return nil +} + +// UnregisterNativeTBTCSignerFallbackObserver clears fallback-observer +// registration. +func UnregisterNativeTBTCSignerFallbackObserver() { + nativeTBTCSignerFallbackObserverMutex.Lock() + defer nativeTBTCSignerFallbackObserverMutex.Unlock() + + nativeTBTCSignerFallbackObserver = nil +} + +func emitNativeTBTCSignerFallbackEvent(event NativeTBTCSignerFallbackEvent) { + nativeTBTCSignerFallbackObserverMutex.RLock() + observer := nativeTBTCSignerFallbackObserver + nativeTBTCSignerFallbackObserverMutex.RUnlock() + + if observer == nil { + return + } + + observer(event) +} diff --git a/pkg/frost/signing/native_tbtc_signer_fallback_telemetry_test.go b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry_test.go new file mode 100644 index 0000000000..457b9710d2 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_fallback_telemetry_test.go @@ -0,0 +1,75 @@ +package signing + +import ( + "testing" +) + +func TestRegisterNativeTBTCSignerFallbackObserverRejectsNil(t *testing.T) { + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + err := RegisterNativeTBTCSignerFallbackObserver(nil) + if err == nil { + t.Fatal("expected registration error") + } +} + +func TestRegisterNativeTBTCSignerFallbackObserverRejectsDuplicate(t *testing.T) { + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + firstErr := RegisterNativeTBTCSignerFallbackObserver( + func(NativeTBTCSignerFallbackEvent) {}, + ) + if firstErr != nil { + t.Fatalf("unexpected first registration error: [%v]", firstErr) + } + + secondErr := RegisterNativeTBTCSignerFallbackObserver( + func(NativeTBTCSignerFallbackEvent) {}, + ) + if secondErr == nil { + t.Fatal("expected duplicate registration error") + } +} + +func TestEmitNativeTBTCSignerFallbackEvent(t *testing.T) { + UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(UnregisterNativeTBTCSignerFallbackObserver) + + var ( + received bool + actual NativeTBTCSignerFallbackEvent + ) + + err := RegisterNativeTBTCSignerFallbackObserver( + func(event NativeTBTCSignerFallbackEvent) { + received = true + actual = event + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + expected := NativeTBTCSignerFallbackEvent{ + SessionID: "session-1", + Reason: "fallback reason", + KeyGroupSource: "legacy-wallet-pubkey", + LegacyPrivateKeyShareExists: true, + } + + emitNativeTBTCSignerFallbackEvent(expected) + + if !received { + t.Fatal("expected fallback event to be delivered") + } + + if actual != expected { + t.Fatalf( + "unexpected fallback event\nexpected: [%+v]\nactual: [%+v]", + expected, + actual, + ) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_material.go b/pkg/frost/signing/native_tbtc_signer_material.go new file mode 100644 index 0000000000..3b5391e391 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_material.go @@ -0,0 +1,57 @@ +package signing + +import ( + "os" + "strings" +) + +const ( + // NativeSignerMaterialFormatFrostTBTCSignerV1 carries signer material for + // tbtc-signer coarse session APIs. + NativeSignerMaterialFormatFrostTBTCSignerV1 = "frost-tbtc-signer-v1" + // NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey marks scaffold-era + // key-group derivation from the legacy wallet public key. Material built + // with this source is placeholder data, not the output of a real FROST DKG + // run, and is refused by default at signing time. See + // `AcceptScaffoldKeyGroupEnvVar` for the opt-in escape hatch. + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey = "legacy-wallet-pubkey" + + // AcceptScaffoldKeyGroupEnvVar is the operator-facing opt-in that allows + // the FROST tbtc-signer FFI path to accept signer material whose + // `KeyGroupSource` is `legacy-wallet-pubkey`. Production deployments must + // not set this; it exists for local dev, CI, and integration rehearsals + // where a real DKG hand-off is not yet wired. + AcceptScaffoldKeyGroupEnvVar = "KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP" +) + +// NativeTBTCSignerMaterialPayload is the signer-material payload schema for +// `frost-tbtc-signer-v1`. +type NativeTBTCSignerMaterialPayload struct { + KeyGroup string `json:"keyGroup"` + KeyGroupSource string `json:"keyGroupSource,omitempty"` + LegacyPrivateKeyShareHex string `json:"legacyPrivateKeyShareHex,omitempty"` +} + +// AcceptScaffoldKeyGroupEnabled reports whether the operator has opted into +// accepting scaffold-era (legacy-wallet-pubkey) key-group material. Without +// this, the signer material resolver and the FFI signing primitive both +// refuse legacy material rather than silently signing with placeholder +// cryptographic context. +// +// The env var is parsed identically to the bootstrap-mode flag in +// `pkg/frost/signing/backend.go`: case-insensitive `1`, `true`, `yes`, or +// `on`. Anything else (including missing/empty) is treated as disabled, so +// the safe-by-default behavior is to refuse. +func AcceptScaffoldKeyGroupEnabled() bool { + raw, ok := os.LookupEnv(AcceptScaffoldKeyGroupEnvVar) + if !ok { + return false + } + + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/pkg/frost/signing/request.go b/pkg/frost/signing/request.go new file mode 100644 index 0000000000..e14b4da13c --- /dev/null +++ b/pkg/frost/signing/request.go @@ -0,0 +1,61 @@ +package signing + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +// Request carries execution input for a FROST signing backend. +type Request struct { + Message *big.Int + SessionID string + MemberIndex group.MemberIndex + // SignerMaterial carries backend-specific signer material. + // Legacy backend expects *tecdsa.PrivateKeyShare. + SignerMaterial any + // PrivateKeyShare is a deprecated legacy alias kept for backward + // compatibility while migrating to backend-specific signer material. + PrivateKeyShare *tecdsa.PrivateKeyShare + GroupSize int + DishonestThreshold int + Channel net.BroadcastChannel + MembershipValidator *group.MembershipValidator + Attempt *Attempt +} + +// LegacyPrivateKeyShare resolves the tECDSA private key share required by the +// transitional legacy execution backend. +// +// It first checks the deprecated Request.PrivateKeyShare field for backward +// compatibility, and then falls back to Request.SignerMaterial. +func (r *Request) LegacyPrivateKeyShare() (*tecdsa.PrivateKeyShare, error) { + if r == nil { + return nil, fmt.Errorf("request is nil") + } + + if r.PrivateKeyShare != nil { + return r.PrivateKeyShare, nil + } + + if r.SignerMaterial == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + privateKeyShare, ok := r.SignerMaterial.(*tecdsa.PrivateKeyShare) + if !ok { + return nil, fmt.Errorf( + "legacy signing material has wrong type: [%T]", + r.SignerMaterial, + ) + } + + if privateKeyShare == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + return privateKeyShare, nil +} diff --git a/pkg/frost/signing/request_test.go b/pkg/frost/signing/request_test.go new file mode 100644 index 0000000000..388b998e10 --- /dev/null +++ b/pkg/frost/signing/request_test.go @@ -0,0 +1,120 @@ +package signing + +import ( + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestRequest_LegacyPrivateKeyShare_FromDeprecatedField(t *testing.T) { + expected := new(tecdsa.PrivateKeyShare) + + request := &Request{ + PrivateKeyShare: expected, + } + + actual, err := request.LegacyPrivateKeyShare() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actual != expected { + t.Fatalf( + "unexpected private key share\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_FromSignerMaterial(t *testing.T) { + expected := new(tecdsa.PrivateKeyShare) + + request := &Request{ + SignerMaterial: expected, + } + + actual, err := request.LegacyPrivateKeyShare() + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if actual != expected { + t.Fatalf( + "unexpected private key share\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_NilRequest(t *testing.T) { + _, err := (*Request)(nil).LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "request is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "request is nil", + err, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_NilMaterial(t *testing.T) { + _, err := (&Request{}).LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "legacy private key share is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "legacy private key share is nil", + err, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_WrongMaterialType(t *testing.T) { + request := &Request{ + SignerMaterial: "invalid", + } + + _, err := request.LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "legacy signing material has wrong type") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "legacy signing material has wrong type", + err, + ) + } +} + +func TestRequest_LegacyPrivateKeyShare_NilTypedMaterial(t *testing.T) { + var typedNil *tecdsa.PrivateKeyShare + + request := &Request{ + SignerMaterial: typedNil, + } + + _, err := request.LegacyPrivateKeyShare() + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "legacy private key share is nil") { + t.Fatalf( + "unexpected error\nexpected substring: [%s]\nactual: [%v]", + "legacy private key share is nil", + err, + ) + } +} diff --git a/pkg/frost/signing/result.go b/pkg/frost/signing/result.go new file mode 100644 index 0000000000..bff53d34b4 --- /dev/null +++ b/pkg/frost/signing/result.go @@ -0,0 +1,11 @@ +package signing + +import "github.com/keep-network/keep-core/pkg/frost" + +// Result of the FROST signing protocol. +type Result struct { + // Signature is the BIP-340-style signature produced as result of signing. + Signature *frost.Signature + // Attempt contains execution metadata for the attempt producing Signature. + Attempt *Attempt +} diff --git a/pkg/frost/signing/roast_retry_attempt_handle_default_build.go b/pkg/frost/signing/roast_retry_attempt_handle_default_build.go new file mode 100644 index 0000000000..8bc28b14da --- /dev/null +++ b/pkg/frost/signing/roast_retry_attempt_handle_default_build.go @@ -0,0 +1,50 @@ +//go:build !frost_roast_retry + +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// SetCurrentAttemptHandleForSession is a no-op in the default build: +// the receive loops will never find a handle for any session, so the +// snapshot submission path is dormant. The build-tagged +// implementation does the real registration. +func SetCurrentAttemptHandleForSession( + _ string, + _ roast.AttemptHandle, + _ attempt.AttemptContext, +) { +} + +// ClearCurrentAttemptHandleForSession is a no-op in the default +// build. +func ClearCurrentAttemptHandleForSession(_ string) {} + +// ResetSessionHandleRegistryForTest is a no-op in the default +// build. +func ResetSessionHandleRegistryForTest() {} + +// StartSessionHandleSweeper is a no-op in the default build: with +// no real registry there is nothing to sweep. +func StartSessionHandleSweeper() {} + +// currentAttemptHandleForCollect always returns ok=false in the +// default build, so submitSnapshotIfActive exits without attempting +// the RecordEvidence call. +func currentAttemptHandleForCollect( + _ string, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + return roast.AttemptHandle{}, attempt.AttemptContext{}, false +} + +// CurrentAttemptHandleForSession is the exported alias for +// callers outside the package (e.g. the ROAST-driven signing +// selector in pkg/tbtc). In the default build it is a no-op that +// always returns ok=false. +func CurrentAttemptHandleForSession( + sessionID string, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + return currentAttemptHandleForCollect(sessionID) +} diff --git a/pkg/frost/signing/roast_retry_attempt_handle_frost_roast_retry.go b/pkg/frost/signing/roast_retry_attempt_handle_frost_roast_retry.go new file mode 100644 index 0000000000..653a162b25 --- /dev/null +++ b/pkg/frost/signing/roast_retry_attempt_handle_frost_roast_retry.go @@ -0,0 +1,177 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// SessionHandleBindingTTL is the maximum age the eviction sweep +// tolerates for a sessionAttemptBinding before treating it as +// orphaned. The two-hour default is documented in RFC-21's +// Resolved decisions section: long enough that no real signing +// session reaches it, short enough that a leaked binding cannot +// accumulate across days of node uptime. +const SessionHandleBindingTTL = 2 * time.Hour + +// SessionHandleSweepInterval is how often the background sweeper +// goroutine wakes up to evict stale bindings. Coarse-grained on +// purpose: the sweep is a defence-in-depth backstop, not a tight +// liveness mechanism. 15 minutes balances responsiveness against +// goroutine churn. +const SessionHandleSweepInterval = 15 * time.Minute + +// sessionAttemptBinding records the current attempt's handle and +// context for a session. The orchestration layer (Phase 5+) sets +// the binding via SetCurrentAttemptHandleForSession before driving +// the round-one / round-two / contribution receive loops; the +// receive loops read it at end-of-collect to know which attempt to +// submit their evidence snapshot against. +// +// createdAt is the wall-clock time at which the binding was last +// (re)set. The background sweeper evicts bindings older than +// SessionHandleBindingTTL. +type sessionAttemptBinding struct { + handle roast.AttemptHandle + context attempt.AttemptContext + createdAt time.Time +} + +var ( + sessionAttemptBindingMu sync.RWMutex + sessionAttemptBindings = map[string]sessionAttemptBinding{} + + sweeperOnce sync.Once + sweeperStop chan struct{} +) + +// SetCurrentAttemptHandleForSession records the in-flight attempt +// handle and context for the named session. Callers in the +// orchestration layer (Phase 5+) invoke this immediately after +// Coordinator.BeginAttempt so receive loops can correlate their +// captured evidence with the right attempt. +// +// Later calls for the same session overwrite earlier ones (this is +// the documented behaviour: a session whose attempt has transitioned +// re-binds to the new attempt's handle). +// +// The binding's createdAt is set to the current wall-clock time so +// the background sweeper can evict it if Clear is never called +// (panic before the deferred clear, etc.). +func SetCurrentAttemptHandleForSession( + sessionID string, + handle roast.AttemptHandle, + ctx attempt.AttemptContext, +) { + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + sessionAttemptBindings[sessionID] = sessionAttemptBinding{ + handle: handle, + context: ctx, + createdAt: time.Now(), + } +} + +// ClearCurrentAttemptHandleForSession removes any binding for the +// named session. Callers invoke this when a session terminates so +// the registry does not grow unbounded. +func ClearCurrentAttemptHandleForSession(sessionID string) { + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + delete(sessionAttemptBindings, sessionID) +} + +// ResetSessionHandleRegistryForTest clears every binding and stops +// the background sweeper if one is running. Exposed only for +// tests; not for production code paths. +func ResetSessionHandleRegistryForTest() { + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + sessionAttemptBindings = map[string]sessionAttemptBinding{} + if sweeperStop != nil { + close(sweeperStop) + sweeperStop = nil + sweeperOnce = sync.Once{} + } +} + +// StartSessionHandleSweeper launches the background goroutine that +// evicts sessionAttemptBindings older than SessionHandleBindingTTL. +// Idempotent via sync.Once: the first caller starts the sweeper; +// subsequent calls are no-ops. The sweeper runs for the lifetime of +// the process (until ResetSessionHandleRegistryForTest stops it, +// which only tests do). +// +// Phase 5.2 starts the sweeper from RegisterRoastRetryCoordinator +// so the defence-in-depth backstop is active whenever orchestration +// could plausibly run. +func StartSessionHandleSweeper() { + sweeperOnce.Do(func() { + sessionAttemptBindingMu.Lock() + sweeperStop = make(chan struct{}) + stop := sweeperStop + sessionAttemptBindingMu.Unlock() + go sessionHandleSweepLoop(stop) + }) +} + +func sessionHandleSweepLoop(stop <-chan struct{}) { + ticker := time.NewTicker(SessionHandleSweepInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + evictStaleSessionHandleBindings(SessionHandleBindingTTL) + } + } +} + +// evictStaleSessionHandleBindings sweeps the binding map and +// removes entries older than maxAge. Exposed at the package level +// so tests can invoke it directly with small maxAge values without +// waiting for the sweeper ticker. +func evictStaleSessionHandleBindings(maxAge time.Duration) int { + cutoff := time.Now().Add(-maxAge) + sessionAttemptBindingMu.Lock() + defer sessionAttemptBindingMu.Unlock() + evicted := 0 + for sessionID, binding := range sessionAttemptBindings { + if binding.createdAt.Before(cutoff) { + delete(sessionAttemptBindings, sessionID) + evicted++ + } + } + return evicted +} + +// currentAttemptHandleForCollect reads the binding the orchestration +// layer set for this session. Returns (zero, zero, false) when no +// binding exists -- the typical Phase-4 state, where no orchestration +// is wired yet. The submit helper takes ok=false as the signal to +// skip the RecordEvidence call. +func currentAttemptHandleForCollect( + sessionID string, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + sessionAttemptBindingMu.RLock() + defer sessionAttemptBindingMu.RUnlock() + binding, ok := sessionAttemptBindings[sessionID] + if !ok { + return roast.AttemptHandle{}, attempt.AttemptContext{}, false + } + return binding.handle, binding.context, true +} + +// CurrentAttemptHandleForSession is the exported alias for callers +// outside the package (e.g. the ROAST-driven signing selector in +// pkg/tbtc). It is identical to currentAttemptHandleForCollect. +func CurrentAttemptHandleForSession( + sessionID string, +) (roast.AttemptHandle, attempt.AttemptContext, bool) { + return currentAttemptHandleForCollect(sessionID) +} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_default_build.go b/pkg/frost/signing/roast_retry_bundle_registry_default_build.go new file mode 100644 index 0000000000..35493ee5a0 --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_default_build.go @@ -0,0 +1,26 @@ +//go:build !frost_roast_retry + +package signing + +import "github.com/keep-network/keep-core/pkg/frost/roast" + +// RecordTransitionBundleForSession is a no-op in the default build: +// the per-session bundle registry is not active without the +// frost_roast_retry tag. The signing-loop ROAST selector (when +// installed via Phase 7's build) reads this registry to consume +// the most recent TransitionMessage for a message. +func RecordTransitionBundleForSession(_ string, _ *roast.TransitionMessage) {} + +// TransitionBundleForSession returns (nil, false) in the default +// build, signalling to callers that no ROAST bundle is available +// and the legacy retry shuffle should be used. +func TransitionBundleForSession(_ string) (*roast.TransitionMessage, bool) { + return nil, false +} + +// ClearTransitionBundleForSession is a no-op in the default build. +func ClearTransitionBundleForSession(_ string) {} + +// ResetTransitionBundleRegistryForTest is a no-op in the default +// build. +func ResetTransitionBundleRegistryForTest() {} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry.go b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry.go new file mode 100644 index 0000000000..41bd306c86 --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry.go @@ -0,0 +1,105 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +// TransitionBundleRegistryTTL is how long a session's most recent +// TransitionMessage is retained before the background sweeper +// evicts it. Matches the session-handle TTL: a bundle's usefulness +// to retry-driven participant selection expires when the session +// it describes is itself archived. +const TransitionBundleRegistryTTL = SessionHandleBindingTTL + +// sessionBundleEntry pairs a TransitionMessage with the wall-clock +// time at which it was recorded so the sweeper can evict stale +// entries. +type sessionBundleEntry struct { + bundle *roast.TransitionMessage + createdAt time.Time +} + +var ( + sessionBundleRegistryMu sync.RWMutex + sessionBundleRegistry = map[string]sessionBundleEntry{} +) + +// RecordTransitionBundleForSession stores the most recent +// TransitionMessage produced by the elected coordinator for the +// named session. The bundle is later consumed by the ROAST-driven +// signingParticipantSelector to compute the next attempt's +// IncludedSet via EvaluateRoastRetryForSigning. +// +// A later call for the same session overwrites the earlier bundle +// -- the registry tracks only the most recent transition. +func RecordTransitionBundleForSession( + sessionID string, + bundle *roast.TransitionMessage, +) { + if bundle == nil { + return + } + sessionBundleRegistryMu.Lock() + defer sessionBundleRegistryMu.Unlock() + sessionBundleRegistry[sessionID] = sessionBundleEntry{ + bundle: bundle, + createdAt: time.Now(), + } +} + +// TransitionBundleForSession returns the most recent transition +// message for the named session, plus a presence flag. Callers +// (the ROAST selector) treat (nil, false) as "no bundle; fall back +// to legacy". +func TransitionBundleForSession( + sessionID string, +) (*roast.TransitionMessage, bool) { + sessionBundleRegistryMu.RLock() + defer sessionBundleRegistryMu.RUnlock() + entry, ok := sessionBundleRegistry[sessionID] + if !ok { + return nil, false + } + return entry.bundle, true +} + +// ClearTransitionBundleForSession removes any bundle for the named +// session. Called when a session terminates. +func ClearTransitionBundleForSession(sessionID string) { + sessionBundleRegistryMu.Lock() + defer sessionBundleRegistryMu.Unlock() + delete(sessionBundleRegistry, sessionID) +} + +// ResetTransitionBundleRegistryForTest clears every bundle. Test- +// only seam. +func ResetTransitionBundleRegistryForTest() { + sessionBundleRegistryMu.Lock() + defer sessionBundleRegistryMu.Unlock() + sessionBundleRegistry = map[string]sessionBundleEntry{} +} + +// evictStaleTransitionBundles sweeps the registry and removes +// entries older than maxAge. Exposed at the package level so +// tests can invoke it directly with small maxAge values. The +// production sweeper invokes it from sessionHandleSweepLoop +// (Phase 5.2) so the bundle and handle registries share a single +// background goroutine. +func evictStaleTransitionBundles(maxAge time.Duration) int { + cutoff := time.Now().Add(-maxAge) + sessionBundleRegistryMu.Lock() + defer sessionBundleRegistryMu.Unlock() + evicted := 0 + for sessionID, entry := range sessionBundleRegistry { + if entry.createdAt.Before(cutoff) { + delete(sessionBundleRegistry, sessionID) + evicted++ + } + } + return evicted +} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry_test.go new file mode 100644 index 0000000000..cca286467c --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_frost_roast_retry_test.go @@ -0,0 +1,109 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +func TestTransitionBundleRegistry_RoundTrip(t *testing.T) { + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetTransitionBundleRegistryForTest) + + bundle := &roast.TransitionMessage{ + CoordinatorIDValue: 7, + } + RecordTransitionBundleForSession("session-A", bundle) + + got, ok := TransitionBundleForSession("session-A") + if !ok { + t.Fatal("expected bundle to be present after Record") + } + if got.CoordinatorIDValue != 7 { + t.Fatalf( + "bundle round-trip mismatch: got coordinator %d, want 7", + got.CoordinatorIDValue, + ) + } +} + +func TestTransitionBundleRegistry_LaterRecordOverwrites(t *testing.T) { + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetTransitionBundleRegistryForTest) + + RecordTransitionBundleForSession("session-B", &roast.TransitionMessage{CoordinatorIDValue: 1}) + RecordTransitionBundleForSession("session-B", &roast.TransitionMessage{CoordinatorIDValue: 2}) + got, ok := TransitionBundleForSession("session-B") + if !ok { + t.Fatal("expected bundle to be present") + } + if got.CoordinatorIDValue != 2 { + t.Fatalf( + "later Record must overwrite earlier: got %d, want 2", + got.CoordinatorIDValue, + ) + } +} + +func TestTransitionBundleRegistry_ClearRemovesBundle(t *testing.T) { + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetTransitionBundleRegistryForTest) + + RecordTransitionBundleForSession("session-clear", &roast.TransitionMessage{}) + if _, ok := TransitionBundleForSession("session-clear"); !ok { + t.Fatal("setup: bundle must exist") + } + ClearTransitionBundleForSession("session-clear") + if _, ok := TransitionBundleForSession("session-clear"); ok { + t.Fatal("bundle must be removed after Clear") + } +} + +func TestTransitionBundleRegistry_NilBundleIsIgnored(t *testing.T) { + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetTransitionBundleRegistryForTest) + + RecordTransitionBundleForSession("session-nil", nil) + if _, ok := TransitionBundleForSession("session-nil"); ok { + t.Fatal("nil bundle must be discarded") + } +} + +func TestEvictStaleTransitionBundles_RemovesOldEntries(t *testing.T) { + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetTransitionBundleRegistryForTest) + + RecordTransitionBundleForSession("session-old", &roast.TransitionMessage{CoordinatorIDValue: 1}) + // Backdate. + sessionBundleRegistryMu.Lock() + entry := sessionBundleRegistry["session-old"] + entry.createdAt = time.Now().Add(-10 * time.Minute) + sessionBundleRegistry["session-old"] = entry + sessionBundleRegistryMu.Unlock() + + RecordTransitionBundleForSession("session-new", &roast.TransitionMessage{CoordinatorIDValue: 2}) + + evicted := evictStaleTransitionBundles(5 * time.Minute) + if evicted != 1 { + t.Fatalf("expected 1 eviction, got %d", evicted) + } + if _, ok := TransitionBundleForSession("session-old"); ok { + t.Fatal("old bundle must be evicted") + } + if _, ok := TransitionBundleForSession("session-new"); !ok { + t.Fatal("new bundle must survive") + } +} + +func TestTransitionBundleRegistryTTL_MatchesSessionHandleTTL(t *testing.T) { + if TransitionBundleRegistryTTL != SessionHandleBindingTTL { + t.Fatalf( + "bundle TTL %s != session-handle TTL %s; bundles must not outlive sessions", + TransitionBundleRegistryTTL, + SessionHandleBindingTTL, + ) + } +} diff --git a/pkg/frost/signing/roast_retry_bundle_registry_test.go b/pkg/frost/signing/roast_retry_bundle_registry_test.go new file mode 100644 index 0000000000..d0b1c6204a --- /dev/null +++ b/pkg/frost/signing/roast_retry_bundle_registry_test.go @@ -0,0 +1,34 @@ +//go:build !frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +func TestTransitionBundleRegistry_DefaultBuildIsNoOp(t *testing.T) { + // In the default build the registry is a permanent stub: + // RecordTransitionBundleForSession discards; TransitionBundleForSession + // always returns (nil, false). The ROAST selector must therefore + // always fall back to legacy retry in the default build. + RecordTransitionBundleForSession( + "session-default-build-test", + &roast.TransitionMessage{}, + ) + got, ok := TransitionBundleForSession("session-default-build-test") + if ok { + t.Fatalf( + "default build registry must report not-present; got bundle %v", + got, + ) + } + if got != nil { + t.Fatalf("default build must return nil bundle; got %v", got) + } + + // Clear and reset must not panic. + ClearTransitionBundleForSession("session-default-build-test") + ResetTransitionBundleRegistryForTest() +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_default_build.go b/pkg/frost/signing/roast_retry_executor_entry_default_build.go new file mode 100644 index 0000000000..96e21f9ba5 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_default_build.go @@ -0,0 +1,26 @@ +//go:build !frost_native + +package signing + +import "github.com/ipfs/go-log/v2" + +// attemptRoastRetryOrchestrationFromRequest is the executor-adapter +// entry point for RFC-21 Phase-6 ROAST orchestration. In the +// default build (no frost_native tag) it is a permanent no-op +// stub: orchestration cannot run without the frost_native code +// path, so the executor adapter behaves exactly as in Phase 5. +// +// The function returns (cleanup, error). cleanup is non-nil when +// orchestration started successfully; the executor adapter defers +// it. error is non-nil only for RUNTIME failures the executor +// must propagate to its caller (static-configuration errors are +// logged and the cleanup is returned nil to signal "no +// orchestration; fall back to legacy receive-loop semantics"). +// +// The default-build stub returns (nil, nil) unconditionally. +func attemptRoastRetryOrchestrationFromRequest( + _ *NativeExecutionFFISigningRequest, + _ log.StandardLogger, +) (func(), error) { + return nil, nil +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_native.go b/pkg/frost/signing/roast_retry_executor_entry_frost_native.go new file mode 100644 index 0000000000..bbb79e7f33 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_native.go @@ -0,0 +1,96 @@ +//go:build frost_native + +package signing + +import ( + "errors" + "fmt" + + "github.com/ipfs/go-log/v2" +) + +// attemptRoastRetryOrchestrationFromRequest is the executor-adapter +// entry point for RFC-21 Phase-6 ROAST orchestration. It: +// +// 1. Builds an attempt.AttemptContext from the FFI signing +// request (BuildAttemptContextFromRequest, gated frost_native). +// +// 2. If construction fails with ErrUnsupportedSignerMaterialFormat +// -- e.g. the deployment still uses FrostUniFFIV1 material -- +// the failure is a STATIC configuration condition: every +// honest signer with the same deployment material observes the +// same error deterministically. Log at INFO and return +// (nil, nil) so the executor proceeds without orchestration. +// +// 3. Any other AttemptContext construction error is a RUNTIME +// failure (nil fields, malformed material payload, etc.). Per +// the RFC-21 Phase-6 orchestration error taxonomy, runtime +// errors must HARD FAIL to prevent group fracture: node A +// falling back to legacy while node B proceeds with ROAST +// would split the participant set on NextAttempt. +// +// 4. Calls BeginOrchestrationForSession with the context. +// ErrRoastRetryReadinessOptOut and +// ErrNoRoastRetryCoordinatorRegistered are static-configuration +// errors -- log at INFO and return (nil, nil). Any other error +// is treated as RUNTIME and propagated unchanged. +// +// 5. On success returns the cleanup function the executor adapter +// must defer. +// +// The function returns (cleanup, error): +// - cleanup non-nil + error nil -> orchestration active; defer cleanup. +// - cleanup nil + error nil -> static fallback; proceed legacy. +// - cleanup nil + error non-nil -> runtime failure; propagate. +func attemptRoastRetryOrchestrationFromRequest( + request *NativeExecutionFFISigningRequest, + logger log.StandardLogger, +) (func(), error) { + if logger == nil { + // Defensive: existing executor-adapter tests pass nil here. + // The helper logs static-fallback diagnostics, so a nil + // logger must not panic the executor. + logger = log.Logger("keep-frost-roast-orchestration") + } + ctx, err := BuildAttemptContextFromRequest(request) + if err != nil { + // All BuildAttemptContextFromRequest errors are treated as + // STATIC fallbacks because they are deterministic per-input: + // the same NativeExecutionFFISigningRequest produces the + // same construction outcome on every honest node, so + // every node would make the same fall-back decision. The + // RFC-21 Phase-6 hard-fail discipline applies only to + // non-deterministic RUNTIME errors that originate inside + // the Coordinator state machine (next branch). + logger.Infof( + "ROAST orchestration unavailable for session %q: %v", + request.SessionID, + err, + ) + return nil, nil + } + + handle, cleanup, err := BeginOrchestrationForSession(request.SessionID, ctx) + if err != nil { + switch { + case errors.Is(err, ErrRoastRetryReadinessOptOut), + errors.Is(err, ErrNoRoastRetryCoordinatorRegistered): + // Static-configuration errors -> safe to fall back. + logger.Infof( + "ROAST retry disabled for session %q: %v", + request.SessionID, + err, + ) + return nil, nil + default: + // Runtime failure: HARD FAIL. + return nil, fmt.Errorf( + "ROAST orchestration: begin session %q: %w", + request.SessionID, + err, + ) + } + } + _ = handle // Phase 6.4+ uses this for retry adapter invocation. + return cleanup, nil +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go b/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go new file mode 100644 index 0000000000..ec521b1335 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go @@ -0,0 +1,121 @@ +//go:build frost_native + +package signing + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newEntryTestRequest(t *testing.T) *NativeExecutionFFISigningRequest { + t.Helper() + const hexKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + payload, _ := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "id", + Data: []byte{0x01}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: hexKey, + }, + }) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "executor-entry-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5}, + }, + } +} + +func TestEntry_StaticFallback_NoCoordinatorRegistered_TaggedBuild(t *testing.T) { + // Without the frost_roast_retry build tag this is exercised by + // the default-build test (which always falls through). Under the + // frost_native build alone, the helper still treats the absence + // of a registered coordinator as a static fallback because + // BeginOrchestrationForSession returns + // ErrNoRoastRetryCoordinatorRegistered (in the default build it + // is the stub no-op-return-true). + // + // The helper must return (nil, nil) regardless: the executor + // adapter proceeds without orchestration, matching Phase 5 + // receive semantics. + logger := log.Logger("entry-static-test") + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + newEntryTestRequest(t), logger, + ) + if err != nil { + t.Fatalf("static fallback must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_StaticFallback_UnsupportedSignerFormat(t *testing.T) { + // FrostUniFFIV1 material -> ExtractDkgGroupPublicKeyFromMaterial + // returns ErrUnsupportedSignerMaterialFormat. The helper must + // treat this as STATIC (deterministic across deployments) and + // fall back without surfacing an error. + req := newEntryTestRequest(t) + req.SignerMaterial = &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte("{}"), + } + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + req, log.Logger("entry-v1-test"), + ) + if err != nil { + t.Fatalf("V1 material must be a static fallback: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_StaticFallback_OnNilSignerMaterial(t *testing.T) { + // Nil signer material is a deterministic, per-input + // construction-precondition failure: every honest node with + // the same request would observe it identically. Treated as a + // STATIC fallback so the executor adapter proceeds without + // orchestration. The HARD-FAIL discipline is reserved for + // non-deterministic Coordinator state-machine errors. + req := newEntryTestRequest(t) + req.SignerMaterial = nil + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + req, log.Logger("entry-nil-mat-test"), + ) + if err != nil { + t.Fatalf("nil signer material must be a STATIC fallback; got %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return cleanup") + } +} + +func TestEntry_StaticFallback_OnZeroAttemptNumber(t *testing.T) { + // Zero attempt number is also a deterministic precondition + // failure; treated as STATIC fallback. + req := newEntryTestRequest(t) + req.Attempt.Number = 0 + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + req, log.Logger("entry-zero-attempt-test"), + ) + if err != nil { + t.Fatalf("zero attempt number must be a STATIC fallback; got %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return cleanup") + } +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go new file mode 100644 index 0000000000..6329394de6 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go @@ -0,0 +1,197 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "encoding/json" + "errors" + "math/big" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newEntryRetryTestRequest(t *testing.T) *NativeExecutionFFISigningRequest { + t.Helper() + const hexKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + payload, _ := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{ + KeyPackage: &NativeFROSTKeyPackage{ + Identifier: "id", + Data: []byte{0x01}, + }, + PublicKeyPackage: &NativeFROSTPublicKeyPackage{ + VerifyingKey: hexKey, + }, + }) + return &NativeExecutionFFISigningRequest{ + Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}), + SessionID: "executor-entry-retry-test", + MemberIndex: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + }, + Attempt: &Attempt{ + Number: 1, + CoordinatorMemberIndex: 1, + IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5}, + }, + } +} + +func TestEntry_StaticFallback_ReadinessOptInUnset(t *testing.T) { + // Explicitly unset the env var. + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Register a coordinator -- the env var alone keeps us in + // fallback. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + newEntryRetryTestRequest(t), log.Logger("entry-no-optin"), + ) + if err != nil { + t.Fatalf("static fallback (env var unset) must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_StaticFallback_RegistryEmpty(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Registry is empty (no Register call). + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + newEntryRetryTestRequest(t), log.Logger("entry-no-registry"), + ) + if err != nil { + t.Fatalf("static fallback (registry empty) must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_HappyPath_ActivatesOrchestration(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + req := newEntryRetryTestRequest(t) + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + req, log.Logger("entry-happy"), + ) + if err != nil { + t.Fatalf("happy path must not error: %v", err) + } + if cleanup == nil { + t.Fatal("happy path must return a cleanup function") + } + + // Binding must exist for the session. + if _, _, ok := currentAttemptHandleForCollect(req.SessionID); !ok { + t.Fatal("binding must exist after orchestration entry") + } + cleanup() + if _, _, ok := currentAttemptHandleForCollect(req.SessionID); ok { + t.Fatal("binding must be cleared after cleanup") + } +} + +func TestEntry_HardFail_RuntimeBeginAttemptFailure(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Register an erroring coordinator -- BeginAttempt fails for + // runtime reasons. Per the RFC-21 taxonomy, this must HARD FAIL. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: &erroringEntryCoordinator{ + err: errors.New("synthetic begin-attempt runtime failure"), + }, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + newEntryRetryTestRequest(t), log.Logger("entry-hard-fail"), + ) + if err == nil { + t.Fatal("runtime BeginAttempt error must HARD FAIL (not static fallback)") + } + if cleanup != nil { + t.Fatal("hard-fail must not return cleanup") + } + if !contains(err.Error(), "synthetic begin-attempt runtime failure") { + t.Fatalf("error must propagate underlying cause; got %v", err) + } +} + +// erroringEntryCoordinator implements roast.Coordinator with a +// synthetic BeginAttempt failure. Used to verify the HARD-FAIL +// branch of the executor-adapter entry helper. +type erroringEntryCoordinator struct { + err error +} + +func (e *erroringEntryCoordinator) BeginAttempt(_ attempt.AttemptContext) (roast.AttemptHandle, error) { + return roast.AttemptHandle{}, e.err +} +func (e *erroringEntryCoordinator) State(_ roast.AttemptHandle) (roast.AttemptState, error) { + return roast.AttemptStatePending, nil +} +func (e *erroringEntryCoordinator) SelectedCoordinator(_ roast.AttemptHandle) (group.MemberIndex, error) { + return 0, nil +} +func (e *erroringEntryCoordinator) RecordEvidence(_ roast.AttemptHandle, _ *roast.LocalEvidenceSnapshot) error { + return nil +} +func (e *erroringEntryCoordinator) AggregateBundle(_ roast.AttemptHandle) (*roast.TransitionMessage, error) { + return nil, nil +} +func (e *erroringEntryCoordinator) VerifyBundle(_ roast.AttemptHandle, _ *roast.TransitionMessage) error { + return nil +} +func (e *erroringEntryCoordinator) NextAttempt( + _ roast.AttemptHandle, _ *roast.TransitionMessage, _ uint, _ []byte, +) (attempt.AttemptContext, error) { + return attempt.AttemptContext{}, nil +} + +func contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_test.go b/pkg/frost/signing/roast_retry_executor_entry_test.go new file mode 100644 index 0000000000..478042619d --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_test.go @@ -0,0 +1,27 @@ +package signing + +import ( + "testing" + + "github.com/ipfs/go-log/v2" +) + +func TestAttemptRoastRetryOrchestrationFromRequest_DefaultBuildIsNoOp(t *testing.T) { + // In the default build, the helper is a permanent stub returning + // (nil, nil) so the executor adapter behaves exactly as in + // Phase 5: no orchestration, no error, no cleanup deferred. + // + // The tagged-build test surface + // (roast_retry_executor_entry_frost_native_test.go) exercises + // the real branching. + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + &NativeExecutionFFISigningRequest{SessionID: "x"}, + log.Logger("test"), + ) + if err != nil { + t.Fatalf("default-build helper must not return an error; got %v", err) + } + if cleanup != nil { + t.Fatal("default-build helper must not return a cleanup function") + } +} diff --git a/pkg/frost/signing/roast_retry_metrics.go b/pkg/frost/signing/roast_retry_metrics.go new file mode 100644 index 0000000000..d1312ab51e --- /dev/null +++ b/pkg/frost/signing/roast_retry_metrics.go @@ -0,0 +1,121 @@ +package signing + +import ( + "sync/atomic" + + "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastRetryEvidenceCounters holds cumulative event counts across +// the entire process lifetime. They are bumped whenever a +// metrics-emitting recorder records an event. Exposed to keep- +// core's clientinfo registry via RegisterRoastRetryMetrics, which +// operators invoke at process startup. +// +// The counters are intentionally process-wide rather than per- +// session: operators want to see "how many overflow events did +// the node observe today?" rather than "what was the count for +// the third attempt of session 0x1234?". Per-attempt detail is +// already visible in the TransitionMessage payload. +var ( + roastRetryOverflowEvents atomic.Uint64 + roastRetryRejectEvents atomic.Uint64 + roastRetryConflictEvents atomic.Uint64 +) + +// Application label prefix used by RegisterRoastRetryMetrics when +// registering with clientinfo.Registry.ObserveApplicationSource. +// The registry concatenates this with each per-source name, so the +// final metric labels look like "frost_roast_retry_overflow_events_total". +const roastRetryMetricsApplication = "frost_roast_retry" + +const ( + overflowEventsMetricName = "overflow_events_total" + rejectEventsMetricName = "reject_events_total" + conflictEventsMetricName = "conflict_events_total" +) + +// RegisterRoastRetryMetrics registers the cumulative ROAST-retry +// evidence counters with the supplied clientinfo registry. +// Operators call this from the node's startup sequence so the +// counters appear in the Prometheus scrape alongside the other +// keep-core metrics. +// +// The metrics are emitted in every build but only increment when +// the receive loops actually call into the metrics-emitting +// recorder, which happens only when the ROAST-retry registry is +// populated (i.e. the operator has opted in). In default builds +// the counters stay at zero. +func RegisterRoastRetryMetrics(registry *clientinfo.Registry) { + if registry == nil { + return + } + registry.ObserveApplicationSource( + roastRetryMetricsApplication, + map[string]clientinfo.Source{ + overflowEventsMetricName: func() float64 { + return float64(roastRetryOverflowEvents.Load()) + }, + rejectEventsMetricName: func() float64 { + return float64(roastRetryRejectEvents.Load()) + }, + conflictEventsMetricName: func() float64 { + return float64(roastRetryConflictEvents.Load()) + }, + }, + ) +} + +// metricsEmittingRecorder wraps an attempt.EvidenceRecorder with +// the process-wide cumulative counters declared above. Each +// Record*-class method bumps the matching counter and then +// delegates to the inner recorder so the per-attempt bounded +// snapshot still reflects the event for the NextAttempt policy. +// +// Use newMetricsEmittingRecorder to construct; do not instantiate +// directly. +type metricsEmittingRecorder struct { + inner attempt.EvidenceRecorder +} + +func newMetricsEmittingRecorder( + inner attempt.EvidenceRecorder, +) attempt.EvidenceRecorder { + if inner == nil { + return attempt.NoOpRecorder() + } + return &metricsEmittingRecorder{inner: inner} +} + +func (m *metricsEmittingRecorder) RecordOverflow(sender group.MemberIndex) { + roastRetryOverflowEvents.Add(1) + m.inner.RecordOverflow(sender) +} + +func (m *metricsEmittingRecorder) RecordReject( + sender group.MemberIndex, + reason string, +) { + roastRetryRejectEvents.Add(1) + m.inner.RecordReject(sender, reason) +} + +func (m *metricsEmittingRecorder) RecordConflict(sender group.MemberIndex) { + roastRetryConflictEvents.Add(1) + m.inner.RecordConflict(sender) +} + +func (m *metricsEmittingRecorder) Snapshot() attempt.Evidence { + return m.inner.Snapshot() +} + +// resetRoastRetryMetricsForTest clears the cumulative counters. +// Exposed only for the package's own tests; not a production +// helper. +func resetRoastRetryMetricsForTest() { + roastRetryOverflowEvents.Store(0) + roastRetryRejectEvents.Store(0) + roastRetryConflictEvents.Store(0) +} diff --git a/pkg/frost/signing/roast_retry_metrics_test.go b/pkg/frost/signing/roast_retry_metrics_test.go new file mode 100644 index 0000000000..fd5e015255 --- /dev/null +++ b/pkg/frost/signing/roast_retry_metrics_test.go @@ -0,0 +1,116 @@ +package signing + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +func TestMetricsEmittingRecorder_IncrementsOnEachCategory(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) + rec.RecordOverflow(1) + rec.RecordOverflow(2) + rec.RecordReject(3, "validation_gate_rejected") + rec.RecordConflict(4) + rec.RecordConflict(5) + rec.RecordConflict(6) + + if got := roastRetryOverflowEvents.Load(); got != 2 { + t.Fatalf("overflow counter: got %d want 2", got) + } + if got := roastRetryRejectEvents.Load(); got != 1 { + t.Fatalf("reject counter: got %d want 1", got) + } + if got := roastRetryConflictEvents.Load(); got != 3 { + t.Fatalf("conflict counter: got %d want 3", got) + } +} + +func TestMetricsEmittingRecorder_DelegatesSnapshotToInner(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) + rec.RecordOverflow(7) + rec.RecordOverflow(7) + + snap := rec.Snapshot() + if snap.Overflows[7] != 2 { + t.Fatalf( + "inner snapshot must reflect events; got %d want 2", + snap.Overflows[7], + ) + } +} + +func TestMetricsEmittingRecorder_NilInnerFallsBackToNoOp(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(nil) + // Defensive guard: a nil inner recorder must produce a recorder + // that does not panic on Record* calls. The wrapper substitutes + // a NoOp inner. + rec.RecordOverflow(1) + rec.RecordReject(1, "x") + rec.RecordConflict(1) + // Counters STILL increment with the recommended call sites... + // wait, that's wrong. If inner is nil and we substitute NoOp, + // the wrapper is the NoOp recorder, no counters bumped. + if roastRetryOverflowEvents.Load() != 0 { + t.Fatal("nil inner -> NoOp; counters should stay at zero") + } +} + +func TestRoastRetryRecorderForCollect_WrapsBoundedWithMetricsWhenRegistered(t *testing.T) { + resetRoastRetryMetricsForTest() + ResetRoastRetryRegistrationForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + t.Cleanup(ResetRoastRetryRegistrationForTest) + + // Without registration, the recorder is NoOp -- recording does + // not bump the cumulative counters. + rec := roastRetryRecorderForCollect() + rec.RecordOverflow(1) + if roastRetryOverflowEvents.Load() != 0 { + t.Fatal("no registration -> NoOp recorder -> no counter bump") + } +} + +func TestMetricsEmittingRecorder_ConcurrentCountersAreRaceSafe(t *testing.T) { + resetRoastRetryMetricsForTest() + t.Cleanup(resetRoastRetryMetricsForTest) + + rec := newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) + const workers = 16 + const callsPerWorker = 100 + + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < callsPerWorker; j++ { + rec.RecordOverflow(1) + } + }() + } + wg.Wait() + + if got := roastRetryOverflowEvents.Load(); got != uint64(workers*callsPerWorker) { + t.Fatalf( + "concurrent counter: got %d want %d", + got, workers*callsPerWorker, + ) + } +} + +func TestRegisterRoastRetryMetrics_NilRegistryIsNoOp(t *testing.T) { + // Defensive: RegisterRoastRetryMetrics(nil) must not panic so + // optional integration paths can pass through nil. + RegisterRoastRetryMetrics(nil) +} diff --git a/pkg/frost/signing/roast_retry_orchestration.go b/pkg/frost/signing/roast_retry_orchestration.go new file mode 100644 index 0000000000..7685df1534 --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration.go @@ -0,0 +1,200 @@ +package signing + +// Static-vs-runtime error taxonomy (RFC-21 Phase 6 — Resolved Decision). +// +// The orchestration layer in this file participates in a load-bearing +// decision that prevents split-brain group fracture in the ROAST retry +// path. Errors returned through the orchestration boundary are +// classified into one of two categories, and the consumer (the +// signing-loop dispatcher) routes them accordingly: +// +// STATIC errors -> safe to fall back to the legacy retry path. +// Every honest signer observes the same node-local +// configuration state (registry population, build +// tags) at the same startup, so a fallback decision +// is deterministic across the group. No participant +// fork can arise from a static-error fallback. +// Sentinel: ErrNoRoastRetryCoordinatorRegistered. +// Detected via errors.Is in +// signing_loop_roast_dispatcher.go. +// +// RUNTIME errors -> HARD FAIL. No fallback. Any error that arises +// from per-attempt protocol state (BeginAttempt +// internals, AttemptContext binding mismatches, +// transition-bundle verification failures, etc.) +// can be observed by some participants and not +// others within the same attempt. Falling back to +// legacy under those conditions would leave some +// operators running the new code path and others +// running legacy on the same attempt -- the canonical +// definition of split-brain fracture. The +// orchestration layer therefore returns these as +// bare (non-sentinel) errors that the dispatcher +// treats as terminal. +// +// The classification is enforced at this file's boundary: any error +// surfaced from this package that is intended to permit fallback MUST +// be the ErrNoRoastRetryCoordinatorRegistered sentinel (or wrap it for +// errors.Is matching). Wrapping ANY runtime error in the sentinel is a +// safety regression that re-enables split-brain risk; PR reviewers +// should reject it. +// +// Background: this decision was redirected during Phase 5/6 review. +// The earlier design had Coordinator.BeginAttempt failures fall back to +// the legacy retry path on the assumption that BeginAttempt was a +// cheap idempotent setup. Review identified that BeginAttempt mutates +// per-attempt state (session bindings, evidence recorder) and can fail +// from races with concurrent receives or from peer-supplied protocol +// messages -- both of which produce non-deterministic per-participant +// outcomes. The taxonomy was tightened so only true configuration +// errors are fallback-eligible. + +import ( + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ErrNoRoastRetryCoordinatorRegistered is returned by +// BeginOrchestrationForSession when the package-level ROAST-retry +// registry has not been populated by a caller. The error is the +// "static configuration" class per the RFC-21 Phase-6 Resolved +// Decision on orchestration error taxonomy: it is safe to fall +// back to the legacy retry path because every honest signer +// observes the same registry state at the same node startup, so +// the fallback decision is deterministic across the group. +// +// Use errors.Is to detect. +var ErrNoRoastRetryCoordinatorRegistered = errors.New( + "roast orchestration: no coordinator registered", +) + +// BeginOrchestrationForSession encapsulates the per-session +// BeginAttempt + binding-population step the RFC-21 Phase 5 +// orchestration layer performs. Callers in the layer above the +// FROST signing primitive invoke it at session start; the returned +// cleanup function is the matching unbinding step the caller +// defers. +// +// Phase 5.2 ships the helper; Phase 6 wires production call sites +// to invoke it (and to feed the AttemptContext from the resolver +// adapter, etc.). +// +// When the ROAST-retry registry is empty (default build, no caller +// has registered a coordinator), the helper returns an error so +// the caller can fall back to legacy behaviour. The two-arg +// "shape" -- (handle, cleanup, error) -- forces the caller to +// handle the absence of a coordinator explicitly rather than +// silently dropping the orchestration. +func BeginOrchestrationForSession( + sessionID string, + ctx attempt.AttemptContext, +) (roast.AttemptHandle, func(), error) { + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "roast orchestration: %w", + err, + ) + } + deps, ok := RegisteredRoastRetryCoordinator() + if !ok { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "%w: caller should fall back to legacy behaviour", + ErrNoRoastRetryCoordinatorRegistered, + ) + } + if deps.Coordinator == nil { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "roast orchestration: registered RoastRetryDeps has nil Coordinator", + ) + } + handle, err := deps.Coordinator.BeginAttempt(ctx) + if err != nil { + return roast.AttemptHandle{}, nil, fmt.Errorf( + "roast orchestration: begin attempt for session %q: %w", + sessionID, + err, + ) + } + SetCurrentAttemptHandleForSession(sessionID, handle, ctx) + cleanup := func() { + // RFC-21 Phase 7.1: if this node is the elected + // coordinator and the attempt is still in the Collecting + // state at cleanup time (i.e. it did not succeed via + // signature aggregation), produce the TransitionMessage + // and stash it in the per-session bundle registry. Phase + // 7.2's ROAST signingParticipantSelector consumes the + // stashed bundle to compute the next attempt's + // IncludedSet via EvaluateRoastRetryForSigning. + // + // Failures are best-effort and silent: a panic in the + // deferred cleanup is materially worse than a missing + // transition bundle (the next attempt's selector falls + // back to the legacy retry shuffle), so we swallow errors + // rather than propagate them. + maybeProduceTransitionBundle(sessionID, handle, deps) + ClearCurrentAttemptHandleForSession(sessionID) + } + return handle, cleanup, nil +} + +// maybeProduceTransitionBundle attempts to call AggregateBundle on +// the registered Coordinator when (a) the local node is the +// elected coordinator for the attempt and (b) the attempt has not +// already transitioned. The result is stashed via +// RecordTransitionBundleForSession (a no-op in default build); on +// any error path the function returns silently because cleanup +// must not break the signing-flow contract. +// +// In the default build this still compiles because +// RecordTransitionBundleForSession is a no-op stub; calls to +// roast.Coordinator methods compile because pkg/frost/roast is +// not build-tagged. +func maybeProduceTransitionBundle( + sessionID string, + handle roast.AttemptHandle, + deps RoastRetryDeps, +) { + if deps.Coordinator == nil { + return + } + if deps.SelfMember == 0 { + // Without a known self-member, we cannot determine + // whether to aggregate. Skip. + return + } + elected, err := deps.Coordinator.SelectedCoordinator(handle) + if err != nil { + return + } + if elected != group.MemberIndex(deps.SelfMember) { + return + } + state, err := deps.Coordinator.State(handle) + if err != nil { + return + } + if state != roast.AttemptStateCollecting { + // Already transitioned or succeeded -- nothing to do. + return + } + bundle, err := deps.Coordinator.AggregateBundle(handle) + if err != nil { + // Best-effort; the next attempt's selector will fall + // back to the legacy retry shuffle. + return + } + RecordTransitionBundleForSession(sessionID, bundle) +} + +// EndOrchestrationForSession is a convenience for callers that +// did not capture the cleanup function from +// BeginOrchestrationForSession (e.g. callers that pass session +// ownership across function boundaries). It is equivalent to +// invoking the cleanup function returned by Begin. +func EndOrchestrationForSession(sessionID string) { + ClearCurrentAttemptHandleForSession(sessionID) +} diff --git a/pkg/frost/signing/roast_retry_orchestration_bundle_test.go b/pkg/frost/signing/roast_retry_orchestration_bundle_test.go new file mode 100644 index 0000000000..38ca6acde9 --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration_bundle_test.go @@ -0,0 +1,215 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// signingForBundleContext constructs an attempt context whose +// SelectCoordinator will deterministically pick member 1 (for the +// sake of this test). Real production deployments use the +// rotating selection; here we pin a stable handle for assertion. +func signingForBundleContext(t *testing.T, members []group.MemberIndex) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "orchestration-bundle-test", + "key-group", + []byte{0x01, 0x02, 0x03}, + [attempt.MessageDigestLength]byte{0xab}, + 0, + members, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +// realCoordinatorForBundleTest returns an in-memory coordinator +// with NoOp signer/verifier so AggregateBundle path runs end-to- +// end without crypto setup. The coordinator's selfMember is the +// elected coordinator computed from the test context, so +// maybeProduceTransitionBundle invokes AggregateBundle. +func realCoordinatorForBundleTest( + t *testing.T, + ctx attempt.AttemptContext, +) (roast.Coordinator, group.MemberIndex) { + t.Helper() + scratch := roast.NewInMemoryCoordinator() + hScratch, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(hScratch) + coord := roast.NewInMemoryCoordinatorWithSigning( + elected, + roast.NoOpSigner(), + roast.NoOpSignatureVerifier(), + ) + return coord, elected +} + +func TestCleanup_ProducesBundleWhenElectedCoordinator(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetTransitionBundleRegistryForTest) + + ctx := signingForBundleContext(t, []group.MemberIndex{1, 2, 3, 4, 5}) + coord, elected := realCoordinatorForBundleTest(t, ctx) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: coord, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(elected), + }) + + const sessionID = "bundle-producer-session" + handle, cleanup, err := BeginOrchestrationForSession(sessionID, ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + + // Seed at least one snapshot so AggregateBundle's + // non-empty-bundle validation passes. + snap := roast.NewLocalEvidenceSnapshot(elected, ctx.Hash(), attempt.Evidence{}) + // NoOpSigner returns empty bytes but the signature-verification + // pre-check rejects zero-length signatures. Provide a dummy + // non-empty signature; the NoOp verifier accepts any byte + // sequence. + snap.OperatorSignature = []byte{0x01} + if err := coord.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record evidence: %v", err) + } + + // Cleanup must produce + record a bundle (we're the elected + // coordinator and the attempt is still Collecting). + cleanup() + + bundle, ok := TransitionBundleForSession(sessionID) + if !ok { + t.Fatal("elected coordinator's cleanup must produce a bundle") + } + if bundle == nil { + t.Fatal("recorded bundle must not be nil") + } + if bundle.CoordinatorID() != elected { + t.Fatalf( + "bundle coordinator id %d != elected %d", + bundle.CoordinatorID(), elected, + ) + } +} + +func TestCleanup_DoesNotProduceBundleWhenNotElectedCoordinator(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetTransitionBundleRegistryForTest) + + ctx := signingForBundleContext(t, []group.MemberIndex{1, 2, 3, 4, 5}) + _, elected := realCoordinatorForBundleTest(t, ctx) + + // Register with a SELF that is NOT the elected coordinator. + nonElected := group.MemberIndex(elected + 10) // arbitrary non-elected + for _, m := range ctx.IncludedSet { + if m != elected { + nonElected = m + break + } + } + + // Use a fresh coordinator bound to the non-elected member. + coord := roast.NewInMemoryCoordinatorWithSigning( + nonElected, + roast.NoOpSigner(), + roast.NoOpSignatureVerifier(), + ) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: coord, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(nonElected), + }) + + const sessionID = "non-elected-session" + _, cleanup, err := BeginOrchestrationForSession(sessionID, ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + cleanup() + + if _, ok := TransitionBundleForSession(sessionID); ok { + t.Fatal("non-elected coordinator must not produce a bundle") + } +} + +func TestCleanup_AggregateBundleErrorIsSwallowed(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + ResetTransitionBundleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + t.Cleanup(ResetTransitionBundleRegistryForTest) + + // Use the standard coordinator. AggregateBundle will fail + // because the elected coordinator was 'self' but we never + // recorded any snapshots in the coordinator (so the bundle + // would be empty). Actually -- empty bundle violates + // validation. Let me set up a scenario where Aggregate fails. + // + // Strategy: register a coordinator whose BeginAttempt succeeds + // but AggregateBundle returns ErrAttemptStateInvalid because + // we manually transition the state through State. Simpler: + // just call cleanup() twice. The second call sees the + // already-transitioned state and bails out cleanly without + // recording a duplicate bundle. + + ctx := signingForBundleContext(t, []group.MemberIndex{1, 2, 3, 4, 5}) + coord, elected := realCoordinatorForBundleTest(t, ctx) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: coord, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(elected), + }) + + const sessionID = "double-cleanup-session" + handle, cleanup, err := BeginOrchestrationForSession(sessionID, ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + + // Seed snapshot so the first cleanup's AggregateBundle + // succeeds. + snap := roast.NewLocalEvidenceSnapshot(elected, ctx.Hash(), attempt.Evidence{}) + // NoOpSigner returns empty bytes but the signature-verification + // pre-check rejects zero-length signatures. Provide a dummy + // non-empty signature; the NoOp verifier accepts any byte + // sequence. + snap.OperatorSignature = []byte{0x01} + if err := coord.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record evidence: %v", err) + } + + // First cleanup -- bundle recorded. + cleanup() + if _, ok := TransitionBundleForSession(sessionID); !ok { + t.Fatal("first cleanup must record bundle") + } + + // Second cleanup -- state is now Transitioned. AggregateBundle + // returns ErrAttemptStateInvalid; the helper must swallow the + // error rather than panic. + cleanup() // Must not panic. +} diff --git a/pkg/frost/signing/roast_retry_orchestration_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_orchestration_frost_roast_retry_test.go new file mode 100644 index 0000000000..6ef63d85ab --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration_frost_roast_retry_test.go @@ -0,0 +1,305 @@ +//go:build frost_roast_retry + +package signing + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newOrchestrationTestContext(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + "orchestration-session", + "key-group-orchestration", + []byte{0x01, 0x02}, + [attempt.MessageDigestLength]byte{0x77}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +func TestBeginOrchestrationForSession_HappyPath(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + ctx := newOrchestrationTestContext(t) + handle, cleanup, err := BeginOrchestrationForSession("session-A", ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + + // Binding must exist. + gotHandle, gotCtx, ok := currentAttemptHandleForCollect("session-A") + if !ok { + t.Fatal("binding must exist after Begin") + } + if gotHandle != handle { + t.Fatal("binding handle mismatch") + } + if gotCtx.Hash() != ctx.Hash() { + t.Fatal("binding context mismatch") + } + + cleanup() + if _, _, ok := currentAttemptHandleForCollect("session-A"); ok { + t.Fatal("binding must be cleared after cleanup") + } +} + +func TestBeginOrchestrationForSession_ErrorsWhenRegistryEmpty(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Readiness env var is set; the registry is empty -- we expect + // the registry-empty error, not the env-var error. + _, _, err := BeginOrchestrationForSession("session-X", newOrchestrationTestContext(t)) + if err == nil { + t.Fatal("expected error when registry is empty") + } + if !strings.Contains(err.Error(), "no coordinator registered") { + t.Fatalf("error must mention missing registration; got %v", err) + } +} + +func TestBeginOrchestrationForSession_ErrorsWhenReadinessOptInUnset(t *testing.T) { + // Explicitly unset, in case the test runner inherits the env var + // from outside. + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Even with a registered coordinator, the readiness env var + // short-circuits orchestration. This is the load-bearing safety + // property: production builds with the frost_roast_retry tag + // still cannot enter the orchestration path without an explicit + // operator decision. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, _, err := BeginOrchestrationForSession("session-no-optin", newOrchestrationTestContext(t)) + if !errors.Is(err, ErrRoastRetryReadinessOptOut) { + t.Fatalf("expected ErrRoastRetryReadinessOptOut, got %v", err) + } +} + +func TestBeginOrchestrationForSession_ErrorsWhenCoordinatorNil(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: nil, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, _, err := BeginOrchestrationForSession("session-Y", newOrchestrationTestContext(t)) + if err == nil { + t.Fatal("expected error when Coordinator is nil") + } + if !strings.Contains(err.Error(), "nil Coordinator") { + t.Fatalf("error must mention nil coordinator; got %v", err) + } +} + +func TestBeginOrchestrationForSession_PropagatesBeginAttemptError(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // A coordinator whose BeginAttempt always fails. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: &erroringCoordinator{err: errors.New("synthetic begin failure")}, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + _, _, err := BeginOrchestrationForSession("session-Z", newOrchestrationTestContext(t)) + if err == nil { + t.Fatal("expected error from coordinator") + } + if !strings.Contains(err.Error(), "synthetic begin failure") { + t.Fatalf("error must wrap underlying cause; got %v", err) + } +} + +func TestEndOrchestrationForSession_RemovesBinding(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContext(t) + SetCurrentAttemptHandleForSession("session-end", roast.AttemptHandle{}, ctx) + + if _, _, ok := currentAttemptHandleForCollect("session-end"); !ok { + t.Fatal("setup: binding must exist") + } + EndOrchestrationForSession("session-end") + if _, _, ok := currentAttemptHandleForCollect("session-end"); ok { + t.Fatal("binding must be removed after End") + } +} + +func TestEvictStaleSessionHandleBindings_RemovesOldEntries(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Two bindings with different ages. + ctx := newOrchestrationTestContext(t) + SetCurrentAttemptHandleForSession("session-old", roast.AttemptHandle{}, ctx) + // Backdate by forcing the timestamp. + sessionAttemptBindingMu.Lock() + b := sessionAttemptBindings["session-old"] + b.createdAt = time.Now().Add(-10 * time.Minute) + sessionAttemptBindings["session-old"] = b + sessionAttemptBindingMu.Unlock() + + SetCurrentAttemptHandleForSession("session-new", roast.AttemptHandle{}, ctx) + + // Sweep with 5-minute TTL: old must be evicted, new must survive. + evicted := evictStaleSessionHandleBindings(5 * time.Minute) + if evicted != 1 { + t.Fatalf("expected 1 eviction, got %d", evicted) + } + if _, _, ok := currentAttemptHandleForCollect("session-old"); ok { + t.Fatal("session-old must be evicted") + } + if _, _, ok := currentAttemptHandleForCollect("session-new"); !ok { + t.Fatal("session-new must survive") + } +} + +func TestEvictStaleSessionHandleBindings_LeavesFreshEntries(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newOrchestrationTestContext(t) + SetCurrentAttemptHandleForSession("session-fresh", roast.AttemptHandle{}, ctx) + + // Sweep with the default 2-hour TTL: nothing should be evicted. + evicted := evictStaleSessionHandleBindings(SessionHandleBindingTTL) + if evicted != 0 { + t.Fatalf("expected 0 evictions for fresh binding, got %d", evicted) + } +} + +func TestSessionHandleBindingTTL_MatchesRFC(t *testing.T) { + if SessionHandleBindingTTL != 2*time.Hour { + t.Fatalf( + "RFC-21 specifies a 2-hour default TTL; constant is %s", + SessionHandleBindingTTL, + ) + } +} + +func TestStartSessionHandleSweeper_IsIdempotent(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + StartSessionHandleSweeper() + StartSessionHandleSweeper() + StartSessionHandleSweeper() + // sync.Once means only one goroutine started; we don't have a + // direct observable, but ResetSessionHandleRegistryForTest will + // close the stop channel and the goroutine will exit cleanly. + // If sync.Once were broken, double-close on the stop channel + // would panic during cleanup. +} + +func TestRegisterRoastRetryCoordinator_StartsSweeper(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + // Register again to verify sync.Once prevents a second + // sweeper. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 2, + }) + + // Reset should not panic (would panic on double-close if + // sync.Once failed). + ResetSessionHandleRegistryForTest() +} + +// erroringCoordinator returns a synthetic error from BeginAttempt. +// Other methods return zero values or nil; tests that need them +// should use a real coordinator. +type erroringCoordinator struct { + err error +} + +func (e *erroringCoordinator) BeginAttempt(_ attempt.AttemptContext) (roast.AttemptHandle, error) { + return roast.AttemptHandle{}, e.err +} +func (e *erroringCoordinator) State(_ roast.AttemptHandle) (roast.AttemptState, error) { + return roast.AttemptStatePending, nil +} +func (e *erroringCoordinator) SelectedCoordinator(_ roast.AttemptHandle) (group.MemberIndex, error) { + return 0, nil +} +func (e *erroringCoordinator) RecordEvidence(_ roast.AttemptHandle, _ *roast.LocalEvidenceSnapshot) error { + return nil +} +func (e *erroringCoordinator) AggregateBundle(_ roast.AttemptHandle) (*roast.TransitionMessage, error) { + return nil, nil +} +func (e *erroringCoordinator) VerifyBundle(_ roast.AttemptHandle, _ *roast.TransitionMessage) error { + return nil +} +func (e *erroringCoordinator) NextAttempt( + _ roast.AttemptHandle, _ *roast.TransitionMessage, _ uint, _ []byte, +) (attempt.AttemptContext, error) { + return attempt.AttemptContext{}, nil +} diff --git a/pkg/frost/signing/roast_retry_orchestration_test.go b/pkg/frost/signing/roast_retry_orchestration_test.go new file mode 100644 index 0000000000..08e42777cc --- /dev/null +++ b/pkg/frost/signing/roast_retry_orchestration_test.go @@ -0,0 +1,37 @@ +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestBeginOrchestrationForSession_DefaultBuildReturnsError(t *testing.T) { + // In the default build, RegisteredRoastRetryCoordinator always + // returns (zero, false), so the orchestration helper must + // return an error directing the caller to fall back to legacy + // behaviour. This guarantees no production caller can + // accidentally "succeed" into orchestration when the build tag + // is off. + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + + ctx, err := attempt.NewAttemptContext( + "session-default-build", + "key-group", + []byte{0x01}, + [attempt.MessageDigestLength]byte{0x77}, + 0, + []group.MemberIndex{1, 2, 3}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + + _, _, err = BeginOrchestrationForSession("session-default-build", ctx) + if err == nil { + t.Fatal("default build must return error from BeginOrchestrationForSession") + } +} diff --git a/pkg/frost/signing/roast_retry_readiness.go b/pkg/frost/signing/roast_retry_readiness.go new file mode 100644 index 0000000000..1bd700230c --- /dev/null +++ b/pkg/frost/signing/roast_retry_readiness.go @@ -0,0 +1,60 @@ +package signing + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// RoastRetryReadinessOptInEnvVar is the environment variable name +// operators must set to "true" to opt in to RFC-21 ROAST retry +// activation. The variable is read per call -- not cached -- so an +// operator can flip it during a debugging session without +// restarting the node. +// +// Pattern matches the existing +// KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP env var +// from PR #3960: a build tag enables the code path, an env var +// enables the wiring, both must agree for the feature to be live. +const RoastRetryReadinessOptInEnvVar = "KEEP_CORE_FROST_ROAST_RETRY_ENABLED" + +// ErrRoastRetryReadinessOptOut is the error +// EnsureRoastRetryReadinessOptIn returns when the env var is unset +// or set to anything other than "true". Use errors.Is to detect. +var ErrRoastRetryReadinessOptOut = errors.New( + "roast retry readiness: operator opt-in env var is not set to true", +) + +// EnsureRoastRetryReadinessOptIn reads the +// RoastRetryReadinessOptInEnvVar environment variable and returns +// nil if it is set to the string "true" (case-insensitive, +// whitespace-trimmed). Returns ErrRoastRetryReadinessOptOut +// otherwise. +// +// Callers in the orchestration layer invoke this before +// RegisterRoastRetryCoordinator so production builds with the +// frost_roast_retry build tag still refuse to wire orchestration +// without an explicit operator decision. +// +// The function is per-call (not cached) so operators can flip the +// env var dynamically during debugging. +func EnsureRoastRetryReadinessOptIn() error { + if !RoastRetryReadinessOptInEnabled() { + return fmt.Errorf( + "%w: set %s=true to enable", + ErrRoastRetryReadinessOptOut, + RoastRetryReadinessOptInEnvVar, + ) + } + return nil +} + +// RoastRetryReadinessOptInEnabled reports whether the readiness +// env var is currently set to "true". Cheap to call; use this when +// you need a boolean (e.g., to gate a log message) and +// EnsureRoastRetryReadinessOptIn when you need an error. +func RoastRetryReadinessOptInEnabled() bool { + value := strings.TrimSpace(os.Getenv(RoastRetryReadinessOptInEnvVar)) + return strings.EqualFold(value, "true") +} diff --git a/pkg/frost/signing/roast_retry_readiness_test.go b/pkg/frost/signing/roast_retry_readiness_test.go new file mode 100644 index 0000000000..9eb0e82746 --- /dev/null +++ b/pkg/frost/signing/roast_retry_readiness_test.go @@ -0,0 +1,82 @@ +package signing + +import ( + "errors" + "strings" + "testing" +) + +func TestEnsureRoastRetryReadinessOptIn_AcceptsTrue(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +func TestEnsureRoastRetryReadinessOptIn_AcceptsTrueCaseInsensitive(t *testing.T) { + cases := []string{"true", "True", "TRUE", "tRuE"} + for _, value := range cases { + t.Run(value, func(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, value) + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + t.Fatalf("expected nil error for %q, got %v", value, err) + } + }) + } +} + +func TestEnsureRoastRetryReadinessOptIn_AcceptsTrimmedWhitespace(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, " true ") + if err := EnsureRoastRetryReadinessOptIn(); err != nil { + t.Fatalf("expected nil error for whitespace-padded 'true', got %v", err) + } +} + +func TestEnsureRoastRetryReadinessOptIn_RejectsUnset(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + err := EnsureRoastRetryReadinessOptIn() + if !errors.Is(err, ErrRoastRetryReadinessOptOut) { + t.Fatalf("expected ErrRoastRetryReadinessOptOut, got %v", err) + } + if !strings.Contains(err.Error(), RoastRetryReadinessOptInEnvVar) { + t.Fatalf( + "error must mention the env var name to guide operators; got %v", + err, + ) + } +} + +func TestEnsureRoastRetryReadinessOptIn_RejectsOtherValues(t *testing.T) { + cases := []string{"false", "1", "yes", "TRUE_", "tru", "anything"} + for _, value := range cases { + t.Run(value, func(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, value) + err := EnsureRoastRetryReadinessOptIn() + if !errors.Is(err, ErrRoastRetryReadinessOptOut) { + t.Fatalf("expected error for %q, got nil", value) + } + }) + } +} + +func TestRoastRetryReadinessOptInEnabled_MirrorsEnsureResult(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + if !RoastRetryReadinessOptInEnabled() { + t.Fatal("expected true when env var set to true") + } + t.Setenv(RoastRetryReadinessOptInEnvVar, "false") + if RoastRetryReadinessOptInEnabled() { + t.Fatal("expected false when env var set to false") + } +} + +func TestRoastRetryReadinessOptInEnvVar_MatchesRFC(t *testing.T) { + const expected = "KEEP_CORE_FROST_ROAST_RETRY_ENABLED" + if RoastRetryReadinessOptInEnvVar != expected { + t.Fatalf( + "env var name drifted: got %q want %q (must match RFC-21 Phase 5)", + RoastRetryReadinessOptInEnvVar, + expected, + ) + } +} diff --git a/pkg/frost/signing/roast_retry_recorder.go b/pkg/frost/signing/roast_retry_recorder.go new file mode 100644 index 0000000000..4bc2e292d7 --- /dev/null +++ b/pkg/frost/signing/roast_retry_recorder.go @@ -0,0 +1,38 @@ +package signing + +import ( + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// roastRetryRecorderForCollect returns the EvidenceRecorder a FROST +// receive loop should use for its current call. +// +// When the package-level ROAST-retry registry is empty (default +// build, or no caller has invoked RegisterRoastRetryCoordinator), +// the receive loops fall back to attempt.NoOpRecorder() so receive +// semantics match Phase 2 exactly: overflow events are discarded +// without observable effect. +// +// When the registry has a coordinator, the function returns a fresh +// attempt.NewBoundedRecorder(). Each call returns a NEW recorder so +// per-collect evidence does not leak across calls. The caller is +// responsible for capturing the returned recorder if it intends to +// inspect Snapshot() at end-of-collect; in Phase 4.2 we only wire +// the call sites to use the registry. PR 4.3 captures the recorder +// reference and submits its snapshot via Coordinator.RecordEvidence. +// +// This helper is intentionally not build-tagged: it delegates to +// RegisteredRoastRetryCoordinator (which IS build-tagged via the +// roast_retry_registration_* files), so the default-build path +// always sees an empty registry and returns NoOp without paying any +// coordinator-construction cost. +func roastRetryRecorderForCollect() attempt.EvidenceRecorder { + if _, ok := RegisteredRoastRetryCoordinator(); !ok { + return attempt.NoOpRecorder() + } + // Wrap the bounded recorder with the metrics-emitting + // decorator so RecordOverflow/Reject/Conflict bump the + // process-wide cumulative counters that + // RegisterRoastRetryMetrics exposes to clientinfo. + return newMetricsEmittingRecorder(attempt.NewBoundedRecorder()) +} diff --git a/pkg/frost/signing/roast_retry_recorder_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_recorder_frost_roast_retry_test.go new file mode 100644 index 0000000000..96d5ab6a4e --- /dev/null +++ b/pkg/frost/signing/roast_retry_recorder_frost_roast_retry_test.go @@ -0,0 +1,56 @@ +//go:build frost_roast_retry + +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestRoastRetryRecorderForCollect_RecordsOverflowWhenRegistered(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + rec := roastRetryRecorderForCollect() + const sender group.MemberIndex = 3 + rec.RecordOverflow(sender) + rec.RecordOverflow(sender) + snap := rec.Snapshot() + if got := snap.Overflows[sender]; got != 2 { + t.Fatalf( + "expected bounded recorder to accumulate overflows; got %d for sender %d", + got, sender, + ) + } +} + +func TestRoastRetryRecorderForCollect_FallsBackToNoOpAfterReset(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + ResetRoastRetryRegistrationForTest() + + rec := roastRetryRecorderForCollect() + rec.RecordOverflow(5) + if got := rec.Snapshot().Overflows[5]; got != 0 { + t.Fatalf( + "after reset the recorder must be NoOp; got count %d", + got, + ) + } +} diff --git a/pkg/frost/signing/roast_retry_recorder_test.go b/pkg/frost/signing/roast_retry_recorder_test.go new file mode 100644 index 0000000000..cd6fd04089 --- /dev/null +++ b/pkg/frost/signing/roast_retry_recorder_test.go @@ -0,0 +1,76 @@ +package signing + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestRoastRetryRecorderForCollect_NoOpWhenRegistryEmpty(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + rec := roastRetryRecorderForCollect() + // Record an overflow. NoOp recorders must show zero in their + // snapshot regardless of input. + rec.RecordOverflow(group.MemberIndex(1)) + rec.RecordOverflow(group.MemberIndex(2)) + snap := rec.Snapshot() + if len(snap.Overflows) != 0 { + t.Fatalf( + "expected NoOp recorder when registry empty; got %d overflow entries", + len(snap.Overflows), + ) + } +} + +func TestRoastRetryRecorderForCollect_BoundedWhenRegistryPopulated(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + // In the default build, RegisterRoastRetryCoordinator is a + // no-op stub; the registry stays empty and this test asserts + // the same NoOp behaviour as the previous test. The tagged + // build (roast_retry_recorder_frost_roast_retry_test.go) is + // where we assert real BoundedRecorder allocation. + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: 1}) + + rec := roastRetryRecorderForCollect() + if rec == nil { + t.Fatal("recorder must never be nil") + } + // We don't assert the *type* of recorder here because tagged + // vs default builds will return different concrete types; the + // observable contract is that Snapshot() always works. + _ = rec.Snapshot() +} + +func TestRoastRetryRecorderForCollect_NewRecorderEachCall(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + // Even in the default build, the helper returns a recorder + // instance per call. We assert that the snapshot for the first + // call does not leak into the second. + a := roastRetryRecorderForCollect() + a.RecordOverflow(group.MemberIndex(1)) + b := roastRetryRecorderForCollect() + bSnap := b.Snapshot() + if got := bSnap.Overflows[1]; got != 0 { + t.Fatalf( + "second recorder must not share state with first; got overflow count %d for sender 1", + got, + ) + } + // Sanity-check: in the NoOp path, even the first recorder's + // snapshot is empty. + if got := a.Snapshot().Overflows[1]; got != 0 { + // NoOp path: must be 0. + // Tagged path: also 0 (we only registered above; this test + // runs default-build). + _ = got + } + // Silence unused. + _ = attempt.NoOpRecorder() +} diff --git a/pkg/frost/signing/roast_retry_registration_default_build.go b/pkg/frost/signing/roast_retry_registration_default_build.go new file mode 100644 index 0000000000..6a257405b8 --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_default_build.go @@ -0,0 +1,54 @@ +//go:build !frost_roast_retry + +package signing + +import "github.com/keep-network/keep-core/pkg/frost/roast" + +// RoastRetryDeps bundles the per-process dependencies the FROST +// receive loops need to participate in RFC-21 Phase-4 coordinator- +// driven evidence flow: +// +// - Coordinator drives BeginAttempt / RecordEvidence / AggregateBundle +// / VerifyBundle / NextAttempt. +// - Signer produces operator-key signatures over canonical +// snapshot and bundle bytes. +// - Verifier validates signatures on inbound snapshots and bundles. +// +// The type is exported in every build so callers can construct it +// without conditional compilation. In the default build the registry +// is a permanent no-op stub: the receive loops cannot find a +// registered coordinator and therefore fall back to the Phase-2 +// `attempt.NoOpRecorder()` behaviour, preserving exact pre-RFC-21 +// receive semantics. +// +// The real registry behind the `frost_roast_retry` build tag is in +// roast_retry_registration_frost_roast_retry.go. +type RoastRetryDeps struct { + Coordinator roast.Coordinator + Signer roast.Signer + Verifier roast.SignatureVerifier + // SelfMember is the local node's member index. The Coordinator + // is already bound to this value via NewInMemoryCoordinatorWithSigning, + // but receivers need it independently so they can correlate + // AttemptHandles with their own snapshots in later Phase-4 PRs. + SelfMember uint32 +} + +// RegisterRoastRetryCoordinator is a no-op in the default build. +// Callers in production code may invoke it unconditionally; the +// registration only takes effect when the `frost_roast_retry` build +// tag is active. +func RegisterRoastRetryCoordinator(_ RoastRetryDeps) {} + +// RegisteredRoastRetryCoordinator returns (zero, false) in the +// default build, signalling to receivers that ROAST-retry plumbing +// is not active and they should continue to use the Phase-2 +// NoOpRecorder fallback. +func RegisteredRoastRetryCoordinator() (RoastRetryDeps, bool) { + return RoastRetryDeps{}, false +} + +// ResetRoastRetryRegistrationForTest is a no-op in the default +// build. Exposed so tests can call it unconditionally regardless of +// which build is active. +func ResetRoastRetryRegistrationForTest() {} diff --git a/pkg/frost/signing/roast_retry_registration_default_build_test.go b/pkg/frost/signing/roast_retry_registration_default_build_test.go new file mode 100644 index 0000000000..91b0135ba4 --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_default_build_test.go @@ -0,0 +1,27 @@ +//go:build !frost_roast_retry + +package signing + +import "testing" + +func TestRoastRetryRegistration_DefaultBuildIsStub(t *testing.T) { + // Register a non-zero dependency set. Because the default build + // is a no-op stub, the registry must remain empty. + deps := RoastRetryDeps{SelfMember: 7} + RegisterRoastRetryCoordinator(deps) + got, ok := RegisteredRoastRetryCoordinator() + if ok { + t.Fatalf("default build must report not-registered; got ok=true, deps=%+v", got) + } + if got != (RoastRetryDeps{}) { + t.Fatalf("default build must return zero value; got %+v", got) + } +} + +func TestRoastRetryRegistration_DefaultBuildResetIsNoOp(t *testing.T) { + // Reset should not panic even though there is no real state. + ResetRoastRetryRegistrationForTest() + if _, ok := RegisteredRoastRetryCoordinator(); ok { + t.Fatal("default build registry should remain empty after reset") + } +} diff --git a/pkg/frost/signing/roast_retry_registration_frost_roast_retry.go b/pkg/frost/signing/roast_retry_registration_frost_roast_retry.go new file mode 100644 index 0000000000..324da6bf22 --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_frost_roast_retry.go @@ -0,0 +1,71 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +// RoastRetryDeps bundles the per-process dependencies the FROST +// receive loops need under the frost_roast_retry build tag. See the +// default-build file for the doc contract; this declaration is the +// real one used when the build tag is active. +type RoastRetryDeps struct { + Coordinator roast.Coordinator + Signer roast.Signer + Verifier roast.SignatureVerifier + SelfMember uint32 +} + +// roastRetryRegistration is the package-private registry slot. Only +// one set of dependencies can be registered at a time; later +// registrations overwrite earlier ones. Callers wanting to test +// reset behaviour use ResetRoastRetryRegistrationForTest. +var ( + roastRetryRegistrationMu sync.RWMutex + roastRetryRegistration RoastRetryDeps + roastRetryRegistered bool +) + +// RegisterRoastRetryCoordinator stores the per-process ROAST-retry +// dependencies the receive loops will pick up on their next call. +// Safe for concurrent registration / lookup; a later registration +// fully replaces an earlier one (this is the documented behaviour -- +// reconfiguring at runtime is intentional). +// +// As a side effect, the first registration starts the +// session-handle sweeper goroutine that evicts orphaned bindings +// (RFC-21 Phase 5.2 defence-in-depth backstop). Subsequent +// registrations do not restart the sweeper. +func RegisterRoastRetryCoordinator(deps RoastRetryDeps) { + roastRetryRegistrationMu.Lock() + roastRetryRegistration = deps + roastRetryRegistered = true + roastRetryRegistrationMu.Unlock() + StartSessionHandleSweeper() +} + +// RegisteredRoastRetryCoordinator returns the currently-registered +// dependencies and true, or the zero value and false if nothing has +// been registered yet. Receivers use the boolean to decide between +// the bounded recorder path and the Phase-2 NoOp fallback. +func RegisteredRoastRetryCoordinator() (RoastRetryDeps, bool) { + roastRetryRegistrationMu.RLock() + defer roastRetryRegistrationMu.RUnlock() + if !roastRetryRegistered { + return RoastRetryDeps{}, false + } + return roastRetryRegistration, true +} + +// ResetRoastRetryRegistrationForTest clears the registry. Exposed +// so tests in this and downstream packages can reset between cases +// without leaking state. Not intended for production code paths. +func ResetRoastRetryRegistrationForTest() { + roastRetryRegistrationMu.Lock() + defer roastRetryRegistrationMu.Unlock() + roastRetryRegistration = RoastRetryDeps{} + roastRetryRegistered = false +} diff --git a/pkg/frost/signing/roast_retry_registration_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_registration_frost_roast_retry_test.go new file mode 100644 index 0000000000..38130de9f2 --- /dev/null +++ b/pkg/frost/signing/roast_retry_registration_frost_roast_retry_test.go @@ -0,0 +1,97 @@ +//go:build frost_roast_retry + +package signing + +import ( + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" +) + +func TestRoastRetryRegistration_TaggedBuildRoundTrip(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + if _, ok := RegisteredRoastRetryCoordinator(); ok { + t.Fatal("registry must start empty") + } + + coord := roast.NewInMemoryCoordinator() + deps := RoastRetryDeps{ + Coordinator: coord, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 7, + } + RegisterRoastRetryCoordinator(deps) + + got, ok := RegisteredRoastRetryCoordinator() + if !ok { + t.Fatal("expected ok=true after register") + } + if got.SelfMember != 7 { + t.Fatalf("self member mismatch: got %d want 7", got.SelfMember) + } + if got.Coordinator == nil { + t.Fatal("coordinator must round-trip") + } +} + +func TestRoastRetryRegistration_LaterRegistrationOverwrites(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: 1}) + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: 2}) + got, ok := RegisteredRoastRetryCoordinator() + if !ok { + t.Fatal("expected ok=true after register") + } + if got.SelfMember != 2 { + t.Fatalf("later registration must win: got %d want 2", got.SelfMember) + } +} + +func TestRoastRetryRegistration_ResetClearsRegistry(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: 1}) + ResetRoastRetryRegistrationForTest() + if _, ok := RegisteredRoastRetryCoordinator(); ok { + t.Fatal("registry must be empty after reset") + } +} + +func TestRoastRetryRegistration_ConcurrentRegisterAndLookupIsRaceSafe(t *testing.T) { + ResetRoastRetryRegistrationForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + + var wg sync.WaitGroup + const registers = 32 + const lookups = 64 + for i := 0; i < registers; i++ { + wg.Add(1) + i := i + go func() { + defer wg.Done() + RegisterRoastRetryCoordinator(RoastRetryDeps{SelfMember: uint32(i + 1)}) + }() + } + for i := 0; i < lookups; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = RegisteredRoastRetryCoordinator() + }() + } + wg.Wait() + + // We don't assert a specific SelfMember -- registers race against + // each other and any of them can land last. We assert only that + // SOME registration succeeded. + if _, ok := RegisteredRoastRetryCoordinator(); !ok { + t.Fatal("expected at least one register to take effect") + } +} diff --git a/pkg/frost/signing/roast_retry_submit.go b/pkg/frost/signing/roast_retry_submit.go new file mode 100644 index 0000000000..3901e58214 --- /dev/null +++ b/pkg/frost/signing/roast_retry_submit.go @@ -0,0 +1,105 @@ +package signing + +import ( + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastRetryLogger is the logger the snapshot-submission path uses +// for non-fatal diagnostics (submission failures, signature errors). +// A submission failure does not propagate to the signing flow: +// Phase 4 ships the submission code path unused in production, and +// even when wired (Phase 5+) a transient submission failure is +// recoverable by the next attempt's evidence flow. +var roastRetryLogger = log.Logger("keep-frost-roast-retry") + +// submitSnapshotIfActive is invoked at end-of-collect to push the +// receive loop's accumulated evidence into the ROAST coordinator's +// RecordEvidence pipeline. The function is a no-op when any of the +// following is true: +// +// - the ROAST-retry registry is empty (default build, no caller +// has invoked RegisterRoastRetryCoordinator); +// - no session-handle binding exists for sessionID (the typical +// Phase-4 state, where the orchestration layer that calls +// SetCurrentAttemptHandleForSession is not yet implemented); +// - the recorder is a NoOp (no events were captured). +// +// When all three preconditions hold, the function builds a +// LocalEvidenceSnapshot, signs it with the registered Signer, and +// submits it via Coordinator.RecordEvidence. Errors at any step are +// logged at WARN level and otherwise swallowed -- snapshot +// submission must not break the receive loop's primary signing +// behaviour. +func submitSnapshotIfActive( + sessionID string, + recorder attempt.EvidenceRecorder, +) { + if recorder == nil { + return + } + deps, ok := RegisteredRoastRetryCoordinator() + if !ok { + return + } + handle, ctx, ok := currentAttemptHandleForCollect(sessionID) + if !ok { + return + } + evidence := recorder.Snapshot() + if len(evidence.Overflows) == 0 { + // Nothing observed worth submitting; emitting an empty + // snapshot is still meaningful in the ROAST protocol + // (proof-of-attendance) but adds noise to the bundle. + // Phase 4.3 chooses to skip empty submissions; Phase 5 + // orchestration may revisit this if attestations need to + // be unconditional. + return + } + snap := buildSignedSnapshot(deps, ctx, evidence) + if snap == nil { + return + } + if err := deps.Coordinator.RecordEvidence(handle, snap); err != nil { + roastRetryLogger.Warnf( + "roast-retry: RecordEvidence failed for session %q: %v", + sessionID, + err, + ) + } +} + +// buildSignedSnapshot constructs and signs a LocalEvidenceSnapshot +// from the captured evidence. Returns nil and logs on signature +// failure; callers treat nil as "skip submission" and continue. +func buildSignedSnapshot( + deps RoastRetryDeps, + ctx attempt.AttemptContext, + evidence attempt.Evidence, +) *roast.LocalEvidenceSnapshot { + snap := roast.NewLocalEvidenceSnapshot( + group.MemberIndex(deps.SelfMember), + ctx.Hash(), + evidence, + ) + payload, err := roast.CanonicalSnapshotBytes(snap) + if err != nil { + roastRetryLogger.Warnf( + "roast-retry: canonicalising snapshot failed: %v", + err, + ) + return nil + } + sig, err := deps.Signer.Sign(payload) + if err != nil { + roastRetryLogger.Warnf( + "roast-retry: signing snapshot failed: %v", + err, + ) + return nil + } + snap.OperatorSignature = sig + return snap +} diff --git a/pkg/frost/signing/roast_retry_submit_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_submit_frost_roast_retry_test.go new file mode 100644 index 0000000000..7e421a7963 --- /dev/null +++ b/pkg/frost/signing/roast_retry_submit_frost_roast_retry_test.go @@ -0,0 +1,318 @@ +//go:build frost_roast_retry + +package signing + +import ( + "errors" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// captureCoordinator is a roast.Coordinator wrapper that records +// every RecordEvidence call so tests can assert what was submitted. +// It delegates everything else to an embedded real coordinator. +type captureCoordinator struct { + inner roast.Coordinator + mu sync.Mutex + recordedFor []roast.AttemptHandle + recordedSnp []*roast.LocalEvidenceSnapshot + recordErr error +} + +func newCaptureCoordinator(inner roast.Coordinator) *captureCoordinator { + return &captureCoordinator{inner: inner} +} + +func (c *captureCoordinator) BeginAttempt(ctx attempt.AttemptContext) (roast.AttemptHandle, error) { + return c.inner.BeginAttempt(ctx) +} +func (c *captureCoordinator) State(h roast.AttemptHandle) (roast.AttemptState, error) { + return c.inner.State(h) +} +func (c *captureCoordinator) SelectedCoordinator(h roast.AttemptHandle) (group.MemberIndex, error) { + return c.inner.SelectedCoordinator(h) +} +func (c *captureCoordinator) RecordEvidence(h roast.AttemptHandle, s *roast.LocalEvidenceSnapshot) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.recordErr != nil { + return c.recordErr + } + c.recordedFor = append(c.recordedFor, h) + c.recordedSnp = append(c.recordedSnp, s) + return c.inner.RecordEvidence(h, s) +} +func (c *captureCoordinator) AggregateBundle(h roast.AttemptHandle) (*roast.TransitionMessage, error) { + return c.inner.AggregateBundle(h) +} +func (c *captureCoordinator) VerifyBundle(h roast.AttemptHandle, m *roast.TransitionMessage) error { + return c.inner.VerifyBundle(h, m) +} +func (c *captureCoordinator) NextAttempt( + h roast.AttemptHandle, m *roast.TransitionMessage, t uint, pk []byte, +) (attempt.AttemptContext, error) { + return c.inner.NextAttempt(h, m, t, pk) +} + +// deterministicSigner produces SHA256(memberID || payload)-style +// signatures the captureSignatureVerifier accepts. +type deterministicSigner struct { + id group.MemberIndex +} + +func (d *deterministicSigner) Sign(payload []byte) ([]byte, error) { + out := make([]byte, len(payload)+1) + out[0] = byte(d.id) + copy(out[1:], payload) + return out, nil +} + +type deterministicVerifier struct{} + +func (deterministicVerifier) Verify( + payload []byte, signature []byte, signer group.MemberIndex, +) error { + if len(signature) != len(payload)+1 { + return errors.New("deterministicVerifier: length mismatch") + } + if signature[0] != byte(signer) { + return errors.New("deterministicVerifier: signer byte mismatch") + } + for i, b := range payload { + if signature[i+1] != b { + return errors.New("deterministicVerifier: payload byte mismatch") + } + } + return nil +} + +func newTestContextForSubmit(t *testing.T, sessionID string) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContext( + sessionID, + "key-group-submit", + []byte{0xAA}, + [attempt.MessageDigestLength]byte{0x42}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + if err != nil { + t.Fatalf("ctx: %v", err) + } + return ctx +} + +func TestSubmitSnapshotIfActive_NoOpWhenRegistryEmpty(t *testing.T) { + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // No registration, no binding. submit should be a no-op. + recorder := attempt.NewBoundedRecorder() + recorder.RecordOverflow(7) + submitSnapshotIfActive("session-x", recorder) + // Nothing to assert observably: success is the absence of a + // panic and no calls to a non-existent coordinator. +} + +func TestSubmitSnapshotIfActive_NoOpWhenSessionUnbound(t *testing.T) { + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + innerCoord := roast.NewInMemoryCoordinator() + cap := newCaptureCoordinator(innerCoord) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: cap, + Signer: &deterministicSigner{id: 1}, + Verifier: deterministicVerifier{}, + SelfMember: 1, + }) + + recorder := attempt.NewBoundedRecorder() + recorder.RecordOverflow(7) + submitSnapshotIfActive("session-with-no-binding", recorder) + + if len(cap.recordedFor) != 0 { + t.Fatalf( + "expected no RecordEvidence calls when session unbound; got %d", + len(cap.recordedFor), + ) + } +} + +func TestSubmitSnapshotIfActive_NoOpWhenRecorderEmpty(t *testing.T) { + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + innerCoord := roast.NewInMemoryCoordinatorWithSigning( + 1, + &deterministicSigner{id: 1}, + deterministicVerifier{}, + ) + cap := newCaptureCoordinator(innerCoord) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: cap, + Signer: &deterministicSigner{id: 1}, + Verifier: deterministicVerifier{}, + SelfMember: 1, + }) + + ctx := newTestContextForSubmit(t, "session-empty") + handle, err := cap.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + SetCurrentAttemptHandleForSession("session-empty", handle, ctx) + + // Recorder is bounded but has captured zero events. + recorder := attempt.NewBoundedRecorder() + submitSnapshotIfActive("session-empty", recorder) + + if len(cap.recordedFor) != 0 { + t.Fatalf( + "expected no RecordEvidence for empty snapshot; got %d", + len(cap.recordedFor), + ) + } +} + +func TestSubmitSnapshotIfActive_SubmitsSignedSnapshotWhenBoundAndPopulated(t *testing.T) { + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + const selfMember group.MemberIndex = 1 + innerCoord := roast.NewInMemoryCoordinatorWithSigning( + selfMember, + &deterministicSigner{id: selfMember}, + deterministicVerifier{}, + ) + cap := newCaptureCoordinator(innerCoord) + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: cap, + Signer: &deterministicSigner{id: selfMember}, + Verifier: deterministicVerifier{}, + SelfMember: uint32(selfMember), + }) + + ctx := newTestContextForSubmit(t, "session-real") + handle, err := cap.BeginAttempt(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + SetCurrentAttemptHandleForSession("session-real", handle, ctx) + + recorder := attempt.NewBoundedRecorder() + recorder.RecordOverflow(3) + recorder.RecordOverflow(3) + recorder.RecordOverflow(5) + submitSnapshotIfActive("session-real", recorder) + + if len(cap.recordedFor) != 1 { + t.Fatalf("expected 1 RecordEvidence; got %d", len(cap.recordedFor)) + } + if cap.recordedFor[0] != handle { + t.Fatal("RecordEvidence handle mismatch") + } + snap := cap.recordedSnp[0] + if snap.SenderID() != selfMember { + t.Fatalf("snapshot sender: got %d want %d", snap.SenderID(), selfMember) + } + if len(snap.OperatorSignature) == 0 { + t.Fatal("snapshot must be signed") + } + // 2 distinct senders observed. + if len(snap.Overflows) != 2 { + t.Fatalf("expected 2 overflow entries; got %d", len(snap.Overflows)) + } +} + +func TestSetCurrentAttemptHandleForSession_LaterBindingOverwrites(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctxA := newTestContextForSubmit(t, "session-overwrite") + ctxB, _ := attempt.NewAttemptContext( + "session-overwrite", "key-group-submit", []byte{0xAA}, + [attempt.MessageDigestLength]byte{0x42}, 1, + []group.MemberIndex{1, 2, 3, 4, 5}, nil, + ) + h1 := roast.AttemptHandle{} + h2 := roast.AttemptHandle{} + + SetCurrentAttemptHandleForSession("session-overwrite", h1, ctxA) + gotHandle, gotCtx, ok := currentAttemptHandleForCollect("session-overwrite") + if !ok { + t.Fatal("expected binding after first Set") + } + if gotHandle != h1 { + t.Fatal("first binding handle mismatch") + } + if gotCtx.AttemptNumber != ctxA.AttemptNumber { + t.Fatal("first binding context mismatch") + } + + SetCurrentAttemptHandleForSession("session-overwrite", h2, ctxB) + _, gotCtx2, ok := currentAttemptHandleForCollect("session-overwrite") + if !ok { + t.Fatal("expected binding after second Set") + } + if gotCtx2.AttemptNumber != ctxB.AttemptNumber { + t.Fatal("second binding context did not overwrite first") + } +} + +func TestClearCurrentAttemptHandleForSession_RemovesBinding(t *testing.T) { + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetSessionHandleRegistryForTest) + + ctx := newTestContextForSubmit(t, "session-clear") + SetCurrentAttemptHandleForSession("session-clear", roast.AttemptHandle{}, ctx) + if _, _, ok := currentAttemptHandleForCollect("session-clear"); !ok { + t.Fatal("setup: binding must exist") + } + ClearCurrentAttemptHandleForSession("session-clear") + if _, _, ok := currentAttemptHandleForCollect("session-clear"); ok { + t.Fatal("binding must be cleared") + } +} + +func TestSubmitSnapshotIfActive_RecordEvidenceFailureIsLoggedNotPropagated(t *testing.T) { + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + innerCoord := roast.NewInMemoryCoordinatorWithSigning( + 1, &deterministicSigner{id: 1}, deterministicVerifier{}, + ) + cap := newCaptureCoordinator(innerCoord) + cap.recordErr = errors.New("synthetic RecordEvidence failure") + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: cap, + Signer: &deterministicSigner{id: 1}, + Verifier: deterministicVerifier{}, + SelfMember: 1, + }) + + ctx := newTestContextForSubmit(t, "session-failure") + handle, _ := cap.BeginAttempt(ctx) + SetCurrentAttemptHandleForSession("session-failure", handle, ctx) + + recorder := attempt.NewBoundedRecorder() + recorder.RecordOverflow(3) + + // Must not panic. Caller is unaffected. + submitSnapshotIfActive("session-failure", recorder) +} diff --git a/pkg/frost/signing/signing.go b/pkg/frost/signing/signing.go new file mode 100644 index 0000000000..3ea4ab3a63 --- /dev/null +++ b/pkg/frost/signing/signing.go @@ -0,0 +1,121 @@ +package signing + +import ( + "context" + "fmt" + "math/big" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +// Execute runs signing and returns a Schnorr-shaped 64-byte signature. +// +// Transitional note: +// This implementation currently delegates group coordination and cryptographic +// operations to the legacy tECDSA engine and converts the resulting (R, S) +// components to the fixed-width Schnorr signature container. +func Execute( + ctx context.Context, + logger log.StandardLogger, + message *big.Int, + sessionID string, + memberIndex group.MemberIndex, + privateKeyShare *tecdsa.PrivateKeyShare, + groupSize int, + dishonestThreshold int, + channel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + attempt *Attempt, +) (*Result, error) { + request := &Request{ + Message: message, + SessionID: sessionID, + MemberIndex: memberIndex, + SignerMaterial: privateKeyShare, + PrivateKeyShare: privateKeyShare, + GroupSize: groupSize, + DishonestThreshold: dishonestThreshold, + Channel: channel, + MembershipValidator: membershipValidator, + Attempt: attempt, + } + + return ExecuteRequest(ctx, logger, request) +} + +// ExecuteRequest runs signing using a fully-populated request object. +// It clones mutable request metadata needed for execution safety. +func ExecuteRequest( + ctx context.Context, + logger log.StandardLogger, + request *Request, +) (*Result, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + clonedRequest := *request + clonedRequest.Attempt = cloneAttempt(request.Attempt) + + return currentExecutionBackend().Execute( + ctx, + logger, + &clonedRequest, + ) +} + +// RegisterUnmarshallers initializes all required message unmarshallers. +// For now, signing transport message formats are delegated to the legacy +// engine implementation. +func RegisterUnmarshallers(channel net.BroadcastChannel) { + currentExecutionBackend().RegisterUnmarshallers(channel) +} + +// FromTECDSASignature maps a legacy signature to the fixed-width Schnorr +// signature container by preserving R/S values and dropping RecoveryID. +func FromTECDSASignature(signature *tecdsa.Signature) (*frost.Signature, error) { + if signature == nil { + return nil, fmt.Errorf("signature is nil") + } + + if signature.R == nil || signature.S == nil { + return nil, fmt.Errorf("signature components cannot be nil") + } + + if signature.R.Sign() < 0 || signature.S.Sign() < 0 { + return nil, fmt.Errorf("signature components cannot be negative") + } + + rBytes := signature.R.Bytes() + sBytes := signature.S.Bytes() + + if len(rBytes) > frost.SignatureComponentSize { + return nil, fmt.Errorf( + "R component too large: [%d] bytes", + len(rBytes), + ) + } + + if len(sBytes) > frost.SignatureComponentSize { + return nil, fmt.Errorf( + "S component too large: [%d] bytes", + len(sBytes), + ) + } + + frostSignature := &frost.Signature{} + copy( + frostSignature.R[frost.SignatureComponentSize-len(rBytes):], + rBytes, + ) + copy( + frostSignature.S[frost.SignatureComponentSize-len(sBytes):], + sBytes, + ) + + return frostSignature, nil +} diff --git a/pkg/frost/signing/signing_test.go b/pkg/frost/signing/signing_test.go new file mode 100644 index 0000000000..f54ff8cfe4 --- /dev/null +++ b/pkg/frost/signing/signing_test.go @@ -0,0 +1,183 @@ +package signing + +import ( + "context" + "math/big" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestFromTECDSASignature(t *testing.T) { + signature := &tecdsa.Signature{ + R: big.NewInt(0x1234), + S: big.NewInt(0xabcd), + } + + result, err := FromTECDSASignature(signature) + if err != nil { + t.Fatalf("conversion failed: [%v]", err) + } + + if result.R[30] != 0x12 || result.R[31] != 0x34 { + t.Fatalf("unexpected R component bytes") + } + + if result.S[30] != 0xab || result.S[31] != 0xcd { + t.Fatalf("unexpected S component bytes") + } +} + +func TestFromTECDSASignature_ValidationErrors(t *testing.T) { + testData := []struct { + name string + signature *tecdsa.Signature + }{ + { + name: "nil signature", + signature: nil, + }, + { + name: "nil R", + signature: &tecdsa.Signature{ + R: nil, + S: big.NewInt(1), + }, + }, + { + name: "nil S", + signature: &tecdsa.Signature{ + R: big.NewInt(1), + S: nil, + }, + }, + { + name: "negative R", + signature: &tecdsa.Signature{ + R: big.NewInt(-1), + S: big.NewInt(1), + }, + }, + { + name: "negative S", + signature: &tecdsa.Signature{ + R: big.NewInt(1), + S: big.NewInt(-1), + }, + }, + } + + for _, tc := range testData { + t.Run(tc.name, func(t *testing.T) { + _, err := FromTECDSASignature(tc.signature) + if err == nil { + t.Fatal("expected conversion error") + } + }) + } +} + +func TestExecuteRequest_NilRequest(t *testing.T) { + _, err := ExecuteRequest(context.Background(), nil, nil) + if err == nil { + t.Fatal("expected request validation error") + } +} + +func TestExecuteRequest_ClonesAttempt(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + backend := &mockExecutionBackend{ + name: "mock", + result: &Result{}, + } + + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("unexpected backend setup error: [%v]", err) + } + + request := &Request{ + Attempt: &Attempt{ + Number: 2, + CoordinatorMemberIndex: 3, + IncludedMembersIndexes: []group.MemberIndex{1, 3, 5}, + ExcludedMembersIndexes: []group.MemberIndex{2, 4}, + }, + } + + if _, err := ExecuteRequest(context.Background(), nil, request); err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if backend.lastRequest == request { + t.Fatal("expected request clone before backend execution") + } + + if backend.lastRequest.Attempt == request.Attempt { + t.Fatal("expected attempt clone before backend execution") + } + + if !reflect.DeepEqual(backend.lastRequest.Attempt, request.Attempt) { + t.Fatalf( + "unexpected attempt clone\nexpected: [%+v]\nactual: [%+v]", + request.Attempt, + backend.lastRequest.Attempt, + ) + } +} + +func TestExecute_PopulatesSignerMaterialAndLegacyAlias(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + + backend := &mockExecutionBackend{ + name: "mock", + result: &Result{}, + } + + if err := SetExecutionBackend(backend); err != nil { + t.Fatalf("unexpected backend setup error: [%v]", err) + } + + privateKeyShare := new(tecdsa.PrivateKeyShare) + + _, err := Execute( + context.Background(), + nil, + big.NewInt(42), + "session-id", + group.MemberIndex(7), + privateKeyShare, + 10, + 3, + nil, + nil, + nil, + ) + if err != nil { + t.Fatalf("unexpected execute error: [%v]", err) + } + + if backend.lastRequest == nil { + t.Fatal("expected backend request") + } + + if backend.lastRequest.SignerMaterial != privateKeyShare { + t.Fatalf( + "unexpected signer material\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + backend.lastRequest.SignerMaterial, + ) + } + + if backend.lastRequest.PrivateKeyShare != privateKeyShare { + t.Fatalf( + "unexpected legacy private key share alias\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + backend.lastRequest.PrivateKeyShare, + ) + } +} diff --git a/pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json b/pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json new file mode 100644 index 0000000000..841e66180d --- /dev/null +++ b/pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json @@ -0,0 +1,71 @@ +{ + "name": "wallet-pubkey-hash-derivation-vectors", + "version": "v1", + "description": "Cross-repo test vectors for HASH160-based wallet pubkey hash derivation. Both the tbtc bridge contracts (tlabs-xyz/tbtc) and the keep-core FROST protocol (threshold-network/keep-core) must derive the same 20-byte alias from the same input. Drift between the two derivations silently breaks the bridge-protocol identity contract for any wallet whose canonical identity is established cross-repo. This fixture is the tripwire: identical JSON is checked into both repos; each side has a test that reads it and asserts its own derivation function reproduces the expected output. If the two sides diverge, at least one repo's test fails.", + "ecdsa_legacy": [ + { + "name": "secp256k1 generator point (compressed, even y)", + "input": { + "compressedPubKey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + "expected": { + "walletPubKeyHash": "0x751e76e8199196d454941c45d1b3a323f1433bd6" + }, + "note": "Bitcoin's classic generator-point compressed pubkey, well-known HASH160. Corresponds to mainnet address 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2." + }, + { + "name": "Near-zero scalar pubkey (compressed, even y)", + "input": { + "compressedPubKey": "0x02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + }, + "expected": { + "walletPubKeyHash": "0x06afd46bcdfd22ef94ac122aa11f241244a37ecc" + } + }, + { + "name": "tBTC fixture pubkey (matches contracts/tbtc-v2/test/data/ecdsa.ts)", + "input": { + "compressedPubKey": "0x0250863ad64a87ae8a2fe83c1af1a8403cb53f53e486d8511dad8a04887e5b2352" + }, + "expected": { + "walletPubKeyHash": "0xf54a5851e9372b87810a8e60cdd2e7cfd80b6e31" + }, + "note": "Cross-validates against the existing pubKeyHash160 constant in the tBTC ECDSA test fixture data, ensuring this vector matches what the tBTC test suite already pins." + } + ], + "frost_p2tr": [ + { + "name": "Representative FROST x-only output key", + "input": { + "xOnlyOutputKey": "0xb1de1afa17e1cbb20d8a4f8e54f8a55fbf5c8d2da9e1c6c4d1f0c7b3a2e5d4c8" + }, + "expected": { + "walletPubKeyHash": "0xac756e3ad02acf580218a3ba2232b081906be776" + }, + "note": "The high 12 bytes are non-zero, matching the native-shape constraint required by the FROST wallet registration entry point (see PR #431). The expected pubKeyHash is HASH160(0x02 || xOnlyOutputKey)." + }, + { + "name": "All-ones x-only key (regression case)", + "input": { + "xOnlyOutputKey": "0x0101010101010101010101010101010101010101010101010101010101010101" + }, + "expected": { + "walletPubKeyHash": "0x9b596d772a3bfe0335f36c38357f026221212c90" + } + }, + { + "name": "All-max x-only key (boundary case)", + "input": { + "xOnlyOutputKey": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "expected": { + "walletPubKeyHash": "0x2914980c04dec23ab03cfcd610adf39d62d7c5fb" + } + } + ], + "drift_check": { + "tbtc_path": "docs/test-vectors/wallet-pubkey-hash-derivation-vectors-v1.json", + "keep_core_path": "pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json", + "rule": "The byte-identical JSON file must exist at both paths. A future CI check should compare file hashes between repos; for now, the per-repo tests catch derivation drift even if the JSON itself drifts (the harder failure mode is silently identical JSON with different implementations underneath)." + } +} diff --git a/pkg/frost/types.go b/pkg/frost/types.go new file mode 100644 index 0000000000..f1f4b0f069 --- /dev/null +++ b/pkg/frost/types.go @@ -0,0 +1,93 @@ +package frost + +import ( + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcutil" +) + +const ( + // OutputKeySize is the byte length of a Taproot x-only output key. + OutputKeySize = 32 + // SignatureComponentSize is the byte length of each Schnorr signature part. + SignatureComponentSize = 32 + // SignatureSize is the full serialized BIP-340 signature length. + SignatureSize = 2 * SignatureComponentSize +) + +// OutputKey is a Taproot x-only output key used by BIP-340/341. +type OutputKey [OutputKeySize]byte + +// WalletPublicKeyHashCompatibilityAlias computes the 20-byte compatibility +// alias from a Taproot output key: +// HASH160(0x02 || xOnlyOutputKey). +func WalletPublicKeyHashCompatibilityAlias(outputKey OutputKey) [20]byte { + serialized := make([]byte, 0, 1+OutputKeySize) + serialized = append(serialized, byte(0x02)) + serialized = append(serialized, outputKey[:]...) + + hash := btcutil.Hash160(serialized) + + var result [20]byte + copy(result[:], hash) + + return result +} + +// Signature is a 64-byte BIP-340 Schnorr signature split into its two +// 32-byte components: R (x-coordinate nonce commitment) and S (scalar). +type Signature struct { + R [SignatureComponentSize]byte + S [SignatureComponentSize]byte +} + +// Serialize concatenates signature components into a 64-byte value. +func (s *Signature) Serialize() [2 * SignatureComponentSize]byte { + var result [SignatureSize]byte + copy(result[0:SignatureComponentSize], s.R[:]) + copy(result[SignatureComponentSize:], s.S[:]) + return result +} + +// Marshal encodes signature into a 64-byte canonical form. +func (s *Signature) Marshal() ([]byte, error) { + serialized := s.Serialize() + result := make([]byte, SignatureSize) + copy(result, serialized[:]) + return result, nil +} + +// Unmarshal decodes signature from a 64-byte canonical form. +func (s *Signature) Unmarshal(data []byte) error { + if len(data) != SignatureSize { + return fmt.Errorf( + "invalid signature length: [%d], expected [%d]", + len(data), + SignatureSize, + ) + } + + copy(s.R[:], data[:SignatureComponentSize]) + copy(s.S[:], data[SignatureComponentSize:]) + + return nil +} + +// Equals determines whether two signatures are equal. +func (s *Signature) Equals(other *Signature) bool { + if s == nil || other == nil { + return s == other + } + + return s.R == other.R && s.S == other.S +} + +// String returns a hex representation useful in logs. +func (s *Signature) String() string { + serialized := s.Serialize() + return fmt.Sprintf("R: 0x%s, S: 0x%s", + hex.EncodeToString(serialized[0:SignatureComponentSize]), + hex.EncodeToString(serialized[SignatureComponentSize:]), + ) +} diff --git a/pkg/frost/types_test.go b/pkg/frost/types_test.go new file mode 100644 index 0000000000..ef3cbdb520 --- /dev/null +++ b/pkg/frost/types_test.go @@ -0,0 +1,94 @@ +package frost + +import ( + "encoding/hex" + "testing" +) + +func TestWalletPublicKeyHashCompatibilityAlias(t *testing.T) { + outputKeyHex := "11223344556677889900aabbccddeeff00112233445566778899aabbccddeeff" + expectedAliasHex := "c2a27a88d8d03e271e8edc556923e9398619f17c" + + outputKeyBytes, err := hex.DecodeString(outputKeyHex) + if err != nil { + t.Fatalf("failed to decode output key: [%v]", err) + } + + var outputKey OutputKey + copy(outputKey[:], outputKeyBytes) + + actualAlias := WalletPublicKeyHashCompatibilityAlias(outputKey) + actualAliasHex := hex.EncodeToString(actualAlias[:]) + + if actualAliasHex != expectedAliasHex { + t.Fatalf( + "unexpected alias\nactual: [%s]\nexpected: [%s]", + actualAliasHex, + expectedAliasHex, + ) + } +} + +func TestSignatureSerialize(t *testing.T) { + signature := &Signature{} + signature.R = [SignatureComponentSize]byte{0x01, 0x02, 0x03} + signature.S = [SignatureComponentSize]byte{0xaa, 0xbb, 0xcc} + + serialized := signature.Serialize() + + if serialized[0] != 0x01 || serialized[1] != 0x02 || serialized[2] != 0x03 { + t.Fatalf("unexpected R serialization") + } + + if serialized[SignatureComponentSize] != 0xaa || + serialized[SignatureComponentSize+1] != 0xbb || + serialized[SignatureComponentSize+2] != 0xcc { + t.Fatalf("unexpected S serialization") + } +} + +func TestSignatureMarshalUnmarshal(t *testing.T) { + original := &Signature{ + R: [SignatureComponentSize]byte{0x11, 0x22, 0x33}, + S: [SignatureComponentSize]byte{0xaa, 0xbb, 0xcc}, + } + + marshaled, err := original.Marshal() + if err != nil { + t.Fatalf("marshal failed: [%v]", err) + } + + decoded := &Signature{} + if err := decoded.Unmarshal(marshaled); err != nil { + t.Fatalf("unmarshal failed: [%v]", err) + } + + if !original.Equals(decoded) { + t.Fatalf("decoded signature does not match original") + } +} + +func TestSignatureUnmarshal_InvalidLength(t *testing.T) { + signature := &Signature{} + err := signature.Unmarshal([]byte{0x01, 0x02, 0x03}) + if err == nil { + t.Fatal("expected invalid-length unmarshal error") + } +} + +func TestSignatureString(t *testing.T) { + signature := &Signature{ + R: [SignatureComponentSize]byte{0x01, 0x02}, + S: [SignatureComponentSize]byte{0x0a, 0x0b}, + } + + expected := "R: 0x0102000000000000000000000000000000000000000000000000000000000000, S: 0x0a0b000000000000000000000000000000000000000000000000000000000000" + + if signature.String() != expected { + t.Fatalf( + "unexpected signature string\nactual: [%s]\nexpected: [%s]", + signature.String(), + expected, + ) + } +} diff --git a/pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go b/pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go new file mode 100644 index 0000000000..0946d08c6b --- /dev/null +++ b/pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go @@ -0,0 +1,263 @@ +package frost + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "golang.org/x/crypto/ripemd160" //nolint:staticcheck // RIPEMD-160 is intentional for the HASH160 derivation. +) + +// Cross-repo derivation fixture (also checked into the tbtc bridge repo +// at docs/test-vectors/wallet-pubkey-hash-derivation-vectors-v1.json). +// Each repo's test must reproduce the expected output from the same +// input; if either side drifts from the other, at least one repo's +// test fails. Drift between bridge and keep-core silently breaks the +// wallet identity contract for any wallet whose canonical identity is +// established cross-repo (in particular, FROST wallets registered via +// the FROST WalletRegistry will use this derivation). +// +// Path constants follow two different conventions intentionally: +// +// - walletPubKeyHashDerivationVectorsTestPath: package-relative, +// used by os.ReadFile() because `go test ./pkg/frost` runs with +// pkg/frost as the working directory. This is the standard Go +// testdata convention. +// +// - walletPubKeyHashDerivationVectorsRepoPath: repo-root-relative, +// used to compare against fixture.DriftCheck.KeepCorePath +// (which declares the canonical location for cross-repo sync +// tooling). This is what a cross-repo diff tool would use. +// +// The two MUST refer to the same file; the TestDriftCheckMetadata +// assertions verify the fixture's self-declared path matches the +// repo-relative constant exactly. +const ( + walletPubKeyHashDerivationVectorsTestPath = "testdata/wallet-pubkey-hash-derivation-vectors-v1.json" + walletPubKeyHashDerivationVectorsRepoPath = "pkg/frost/testdata/wallet-pubkey-hash-derivation-vectors-v1.json" +) + +type ecdsaVector struct { + Name string `json:"name"` + Input struct { + CompressedPubKey string `json:"compressedPubKey"` + } `json:"input"` + Expected struct { + WalletPubKeyHash string `json:"walletPubKeyHash"` + } `json:"expected"` + Note string `json:"note,omitempty"` +} + +type frostVector struct { + Name string `json:"name"` + Input struct { + XOnlyOutputKey string `json:"xOnlyOutputKey"` + } `json:"input"` + Expected struct { + WalletPubKeyHash string `json:"walletPubKeyHash"` + } `json:"expected"` + Note string `json:"note,omitempty"` +} + +type derivationFixture struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + EcdsaLegacy []ecdsaVector `json:"ecdsa_legacy"` + FrostP2tr []frostVector `json:"frost_p2tr"` + DriftCheck struct { + TbtcPath string `json:"tbtc_path"` + KeepCorePath string `json:"keep_core_path"` + Rule string `json:"rule"` + } `json:"drift_check"` +} + +func loadDerivationFixture(t *testing.T) derivationFixture { + t.Helper() + + data, err := os.ReadFile(walletPubKeyHashDerivationVectorsTestPath) + if err != nil { + t.Fatalf("fixture read: %v", err) + } + var fixture derivationFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("fixture parse: %v", err) + } + if fixture.Version != "v1" { + t.Fatalf( + "fixture schemaVersion drift: got %q, expected %q -- both repos must update together", + fixture.Version, + "v1", + ) + } + return fixture +} + +// TestFrostWalletPubKeyHashDerivationVectors checks that +// frost.WalletPublicKeyHashCompatibilityAlias produces the expected +// 20-byte HASH160(0x02 || xOnlyOutputKey) for every FROST vector in +// the shared cross-repo fixture. The tbtc bridge runs the equivalent +// check against its own derivation (BitcoinTx.deriveWalletPubKeyHash- +// FromXOnly); if either side drifts, the wallet identity contract +// between the bridge and the protocol silently breaks for any FROST +// wallet whose canonical identity is established cross-repo. +func TestFrostWalletPubKeyHashDerivationVectors(t *testing.T) { + fixture := loadDerivationFixture(t) + + if len(fixture.FrostP2tr) == 0 { + t.Fatal("fixture must contain at least one FROST vector") + } + + for _, vector := range fixture.FrostP2tr { + vector := vector + t.Run(vector.Name, func(t *testing.T) { + xOnlyBytes, err := hex.DecodeString( + strings.TrimPrefix(vector.Input.XOnlyOutputKey, "0x"), + ) + if err != nil { + t.Fatalf("decode xOnlyOutputKey: %v", err) + } + if len(xOnlyBytes) != OutputKeySize { + t.Fatalf( + "xOnlyOutputKey length: got %d, expected %d", + len(xOnlyBytes), + OutputKeySize, + ) + } + + var outputKey OutputKey + copy(outputKey[:], xOnlyBytes) + + alias := WalletPublicKeyHashCompatibilityAlias(outputKey) + got := "0x" + hex.EncodeToString(alias[:]) + want := strings.ToLower(vector.Expected.WalletPubKeyHash) + + if got != want { + t.Fatalf( + "derivation drift for vector %q:\n got: %s\n want: %s\n"+ + "\nThis test enforces the cross-repo contract that\n"+ + "frost.WalletPublicKeyHashCompatibilityAlias and the\n"+ + "tbtc bridge's BitcoinTx.deriveWalletPubKeyHashFromXOnly\n"+ + "produce the same 20-byte alias for the same input.\n"+ + "If this test fails, also expect the tbtc-side test to\n"+ + "fail unless the JSON fixture itself has drifted.", + vector.Name, + got, + want, + ) + } + }) + } +} + +// TestEcdsaCompressedPubKeyHash160Vectors checks the legacy ECDSA +// derivation path: HASH160 of the compressed pubkey. The tbtc bridge +// performs this implicitly during registerNewWallet (compress then +// hash160). The off-chain operator tooling that produces deposit +// scripts performs the same derivation; this test pins the algorithm +// from the keep-core side using the same vectors the bridge pins on +// its side. +func TestEcdsaCompressedPubKeyHash160Vectors(t *testing.T) { + fixture := loadDerivationFixture(t) + + if len(fixture.EcdsaLegacy) == 0 { + t.Fatal("fixture must contain at least one ECDSA vector") + } + + for _, vector := range fixture.EcdsaLegacy { + vector := vector + t.Run(vector.Name, func(t *testing.T) { + compressed, err := hex.DecodeString( + strings.TrimPrefix(vector.Input.CompressedPubKey, "0x"), + ) + if err != nil { + t.Fatalf("decode compressedPubKey: %v", err) + } + + got := "0x" + hex.EncodeToString(hash160(compressed)) + want := strings.ToLower(vector.Expected.WalletPubKeyHash) + + if got != want { + t.Fatalf( + "HASH160 drift for vector %q:\n got: %s\n want: %s", + vector.Name, + got, + want, + ) + } + }) + } +} + +// TestDriftCheckMetadata asserts the fixture declares the tbtc mirror +// path and a non-empty drift rule. A future CI sync check can use +// these fields to compare files between repos. The fixture's +// keep_core_path is repo-root-relative by convention; the package- +// relative testdata constant used by os.ReadFile() is a separate +// representation of the same file. +func TestDriftCheckMetadata(t *testing.T) { + fixture := loadDerivationFixture(t) + + if fixture.DriftCheck.TbtcPath != "docs/test-vectors/wallet-pubkey-hash-derivation-vectors-v1.json" { + t.Errorf( + "drift_check.tbtc_path drift: got %q", + fixture.DriftCheck.TbtcPath, + ) + } + if fixture.DriftCheck.KeepCorePath != walletPubKeyHashDerivationVectorsRepoPath { + t.Errorf( + "drift_check.keep_core_path drift: fixture says %q, repo convention is %q", + fixture.DriftCheck.KeepCorePath, + walletPubKeyHashDerivationVectorsRepoPath, + ) + } + if fixture.DriftCheck.Rule == "" { + t.Error("drift_check.rule must be non-empty") + } +} + +// TestFixtureFileShouldExistAtMirrorPath documents the convention that +// the file lives at the path the fixture self-declares. Since the +// fixture's keep_core_path is repo-root-relative but `go test +// ./pkg/frost` runs with pkg/frost as the working directory, the path +// is resolved relative to the repo root by walking up from this test +// file's location. +func TestFixtureFileShouldExistAtMirrorPath(t *testing.T) { + fixture := loadDerivationFixture(t) + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller: cannot locate test source file") + } + // thisFile points at pkg/frost/wallet_pubkey_hash_derivation_vectors_test.go + // repo root is two directories up. + repoRoot := filepath.Clean( + filepath.Join(filepath.Dir(thisFile), "..", ".."), + ) + abs := filepath.Join(repoRoot, fixture.DriftCheck.KeepCorePath) + if _, err := os.Stat(abs); err != nil { + t.Fatalf( + "fixture self-declares it lives at %q (resolved to %q) but the file is not there: %v", + fixture.DriftCheck.KeepCorePath, + abs, + err, + ) + } +} + +// hash160 reproduces Bitcoin's HASH160 (RIPEMD160(SHA256(x))) using +// the same primitive frost.WalletPublicKeyHashCompatibilityAlias +// invokes via btcutil.Hash160. We compute it directly here so the +// ECDSA test is self-contained and doesn't pull in btcutil for a one- +// liner. +func hash160(b []byte) []byte { + sha := sha256.Sum256(b) + rip := ripemd160.New() + rip.Write(sha[:]) + return rip.Sum(nil) +} diff --git a/pkg/generator/scheduler.go b/pkg/generator/scheduler.go index 73c9d25350..014f7b1395 100644 --- a/pkg/generator/scheduler.go +++ b/pkg/generator/scheduler.go @@ -112,10 +112,16 @@ func (s *Scheduler) resume() { // This function should be executed only be the Scheduler and when the // workMutex is locked. func (s *Scheduler) startWorker(workerFn func(context.Context)) { + // #nosec G118 -- The cancel function is retained in s.stops and invoked + // when the scheduler stops workers. ctx, cancelFn := context.WithCancel(context.Background()) - s.stops = append(s.stops, cancelFn) + s.stops = append(s.stops, func() { + cancelFn() + }) go func() { + defer cancelFn() + for { select { case <-ctx.Done(): diff --git a/pkg/net/retransmission/strategy.go b/pkg/net/retransmission/strategy.go index fd50384fb2..cbf30bc433 100644 --- a/pkg/net/retransmission/strategy.go +++ b/pkg/net/retransmission/strategy.go @@ -1,6 +1,10 @@ package retransmission -import "github.com/keep-network/keep-core/pkg/net" +import ( + "sync" + + "github.com/keep-network/keep-core/pkg/net" +) // Strategy represents a specific retransmission strategy. type Strategy interface { @@ -44,6 +48,7 @@ func (ss *StandardStrategy) Tick(retransmitFn RetransmitFn) error { // ticks, between third and fourth is 4 ticks and so on. Graphically, the // schedule looks as follows: R _ R _ _ R _ _ _ _ R _ _ _ _ _ _ _ _ R type BackoffStrategy struct { + mutex sync.Mutex tickCounter uint64 delay uint64 retransmitTick uint64 @@ -61,6 +66,9 @@ func WithBackoffStrategy() *BackoffStrategy { // Tick implements the Strategy.Tick function. func (bos *BackoffStrategy) Tick(retransmitFn RetransmitFn) error { + bos.mutex.Lock() + defer bos.mutex.Unlock() + bos.tickCounter++ if bos.tickCounter == bos.retransmitTick { diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 55206f86fb..76a016c019 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -257,6 +257,10 @@ type BridgeChain interface { // if the wallet was not found. GetWallet(walletPublicKeyHash [20]byte) (*WalletChainData, error) + // WalletPublicKeyHashForWalletID resolves canonical wallet ID to the + // 20-byte compatibility wallet public key hash used by legacy interfaces. + WalletPublicKeyHashForWalletID(walletID [32]byte) ([20]byte, error) + // OnWalletClosed registers a callback that is invoked when an on-chain // notification of the wallet closed is seen. The notification occurs when // the wallet is closed or terminated. @@ -329,6 +333,10 @@ type BridgeChain interface { // NewWalletRegisteredEvent represents a new wallet registered event. type NewWalletRegisteredEvent struct { + // WalletID is the canonical bridge wallet identifier. + // For legacy ECDSA wallets, this is derived as a left-padded + // 20-byte wallet public key hash. + WalletID [32]byte EcdsaWalletID [32]byte WalletPublicKeyHash [20]byte BlockNumber uint64 @@ -338,6 +346,7 @@ type NewWalletRegisteredEvent struct { type NewWalletRegisteredEventFilter struct { StartBlock uint64 EndBlock *uint64 + WalletID [][32]byte EcdsaWalletID [][32]byte WalletPublicKeyHash [][20]byte } @@ -413,6 +422,10 @@ type DepositChainRequest struct { // WalletChainData represents wallet data stored on-chain. type WalletChainData struct { + // WalletID is the canonical bridge wallet identifier. + // For legacy ECDSA wallets, this is derived as a left-padded + // 20-byte wallet public key hash. + WalletID [32]byte EcdsaWalletID [32]byte MainUtxoHash [32]byte PendingRedemptionsValue uint64 diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 15bb4c94ca..e4864c4575 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -892,6 +892,25 @@ func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( return walletChainData, nil } +func (lc *localChain) WalletPublicKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + lc.walletsMutex.Lock() + defer lc.walletsMutex.Unlock() + + for walletPublicKeyHash, walletData := range lc.wallets { + if walletData == nil { + continue + } + + if walletID == walletData.WalletID || walletID == walletData.EcdsaWalletID { + return walletPublicKeyHash, nil + } + } + + return [20]byte{}, fmt.Errorf("wallet not found") +} + func (lc *localChain) IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) { lc.walletsMutex.Lock() defer lc.walletsMutex.Unlock() @@ -916,6 +935,10 @@ func (lc *localChain) setWallet( lc.walletsMutex.Lock() defer lc.walletsMutex.Unlock() + if walletChainData != nil && walletChainData.WalletID == [32]byte{} { + walletChainData.WalletID = DeriveLegacyWalletID(walletPublicKeyHash) + } + lc.wallets[walletPublicKeyHash] = walletChainData } diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index 7b869f9671..701886daab 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -19,7 +19,6 @@ import ( netlocal "github.com/keep-network/keep-core/pkg/net/local" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" "golang.org/x/exp/slices" "github.com/keep-network/keep-core/internal/testutils" @@ -1300,12 +1299,8 @@ func TestCoordinationExecutor_ExecuteFollowerRoutine(t *testing.T) { senderID: leaderID, message: big.NewInt(100), attemptNumber: 2, - signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 3, - }, - endBlock: 4500, + signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), + endBlock: 4500, }) if err != nil { t.Error(err) diff --git a/pkg/tbtc/deposit_sweep_test.go b/pkg/tbtc/deposit_sweep_test.go index c98f75a3c0..08a2c83eaf 100644 --- a/pkg/tbtc/deposit_sweep_test.go +++ b/pkg/tbtc/deposit_sweep_test.go @@ -7,10 +7,9 @@ import ( "testing" "time" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" ) @@ -171,16 +170,15 @@ func TestDepositSweepAction_Execute(t *testing.T) { // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signatures within the scenario fixture are in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack them first. - rawSignatures := make([]*tecdsa.Signature, len(scenario.Signatures)) + // The signatures within the scenario fixture are represented as + // big integer components and need conversion to runtime signature + // containers used by signing executor. + rawSignatures := make([]*frost.Signature, len(scenario.Signatures)) for i, signature := range scenario.Signatures { - rawSignatures[i] = &tecdsa.Signature{ - R: signature.R, - S: signature.S, - } + rawSignatures[i] = mustFrostSignatureFromBigInts( + signature.R, + signature.S, + ) } // Set up the signing executor mock to return the signatures from diff --git a/pkg/tbtc/dkg.go b/pkg/tbtc/dkg.go index 177e225a18..56c08291ee 100644 --- a/pkg/tbtc/dkg.go +++ b/pkg/tbtc/dkg.go @@ -521,11 +521,17 @@ func (de *dkgExecutor) registerSigner( ) } + signerMaterial, err := resolveSignerMaterial(result.PrivateKeyShare) + if err != nil { + return nil, fmt.Errorf("failed to resolve signer material: [%w]", err) + } + signer := newSigner( result.PrivateKeyShare.PublicKey(), finalSigningGroupOperators, finalSigningGroupMemberIndex, result.PrivateKeyShare, + signerMaterial, ) err = de.walletRegistry.registerSigner(signer) diff --git a/pkg/tbtc/dkg_loop.go b/pkg/tbtc/dkg_loop.go index 4b7955abc9..bcd02e02a9 100644 --- a/pkg/tbtc/dkg_loop.go +++ b/pkg/tbtc/dkg_loop.go @@ -199,6 +199,7 @@ func (drl *dkgRetryLoop) start( drl.memberIndex, fmt.Sprintf("%v-%v", drl.seed, drl.attemptCounter), ) + cancelAnnounceCtx() if err != nil { drl.logger.Warnf( "[member:%v] announcement for attempt [%v] "+ diff --git a/pkg/tbtc/heartbeat.go b/pkg/tbtc/heartbeat.go index c86afd88db..64fad1556e 100644 --- a/pkg/tbtc/heartbeat.go +++ b/pkg/tbtc/heartbeat.go @@ -9,8 +9,8 @@ import ( "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" ) const ( @@ -60,7 +60,7 @@ type heartbeatSigningExecutor interface { ctx context.Context, message *big.Int, startBlock uint64, - ) (*tecdsa.Signature, *signingActivityReport, uint64, error) + ) (*frost.Signature, *signingActivityReport, uint64, error) } // heartbeatInactivityClaimExecutor is an interface meant to decouple the diff --git a/pkg/tbtc/heartbeat_test.go b/pkg/tbtc/heartbeat_test.go index c635659a08..7d833f16ab 100644 --- a/pkg/tbtc/heartbeat_test.go +++ b/pkg/tbtc/heartbeat_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" ) func TestHeartbeatAction_HappyPath(t *testing.T) { @@ -612,7 +612,7 @@ func (mhse *mockHeartbeatSigningExecutor) sign( ctx context.Context, message *big.Int, startBlock uint64, -) (*tecdsa.Signature, *signingActivityReport, uint64, error) { +) (*frost.Signature, *signingActivityReport, uint64, error) { mhse.requestedMessage = message mhse.requestedStartBlock = startBlock @@ -636,7 +636,7 @@ func (mhse *mockHeartbeatSigningExecutor) sign( inactiveMembers: inactiveMembers, } - return &tecdsa.Signature{}, activityReport, startBlock + 1, nil + return &frost.Signature{}, activityReport, startBlock + 1, nil } type mockInactivityClaimExecutor struct { diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 3ee310d21b..babbfb34f7 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -12,6 +12,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" "github.com/keep-network/keep-core/pkg/tecdsa" @@ -42,15 +43,18 @@ func (s *signer) Marshal() ([]byte, error) { SigningGroupOperators: walletSigningGroupOperators, } - privateKeyShare, err := s.privateKeyShare.Marshal() + signerMaterialBytes, err := marshalSignerMaterialForPersistence( + s.signerMaterial, + s.privateKeyShare, + ) if err != nil { - return nil, fmt.Errorf("cannot marshal private key share: [%w]", err) + return nil, fmt.Errorf("cannot marshal signer material: [%w]", err) } return proto.Marshal(&pb.Signer{ Wallet: pbWallet, SigningGroupMemberIndex: uint32(s.signingGroupMemberIndex), - PrivateKeyShare: privateKeyShare, + PrivateKeyShare: signerMaterialBytes, }) } @@ -72,9 +76,11 @@ func (s *signer) Unmarshal(bytes []byte) error { chain.Address(pbSigner.Wallet.SigningGroupOperators[i]) } - privateKeyShare := &tecdsa.PrivateKeyShare{} - if err := privateKeyShare.Unmarshal(pbSigner.PrivateKeyShare); err != nil { - return fmt.Errorf("cannot unmarshal private key share: [%w]", err) + signerMaterial, err := unmarshalSignerMaterialFromPersistence( + pbSigner.PrivateKeyShare, + ) + if err != nil { + return fmt.Errorf("cannot unmarshal signer material: [%w]", err) } s.wallet = wallet{ @@ -82,7 +88,8 @@ func (s *signer) Unmarshal(bytes []byte) error { signingGroupOperators: walletSigningGroupOperators, } s.signingGroupMemberIndex = group.MemberIndex(pbSigner.SigningGroupMemberIndex) - s.privateKeyShare = privateKeyShare + s.privateKeyShare = signerMaterial.privateKeyShare + s.signerMaterial = signerMaterial.signerMaterial return nil } @@ -114,7 +121,7 @@ func (sdm *signingDoneMessage) Unmarshal(bytes []byte) error { return err } - signature := &tecdsa.Signature{} + signature := &frost.Signature{} if err := signature.Unmarshal(pbMsg.Signature); err != nil { return fmt.Errorf("cannot unmarshal signature: [%v]", err) } diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 892d234ecc..c1e750f9ec 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -13,9 +13,9 @@ import ( fuzz "github.com/google/gofuzz" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/internal/pbutils" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" ) func TestSignerMarshalling(t *testing.T) { @@ -26,9 +26,7 @@ func TestSignerMarshalling(t *testing.T) { if err := pbutils.RoundTrip(marshaled, unmarshaled); err != nil { t.Fatal(err) } - if !reflect.DeepEqual(marshaled, unmarshaled) { - t.Fatal("unexpected content of unmarshaled signer") - } + assertSignerEquivalent(t, "unmarshaled signer", marshaled, unmarshaled) } func TestSignerMarshalling_NonTECDSAKey(t *testing.T) { @@ -53,12 +51,8 @@ func TestSigningDoneMessage_MarshalingRoundtrip(t *testing.T) { senderID: group.MemberIndex(10), message: big.NewInt(100), attemptNumber: 2, - signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 3, - }, - endBlock: 4500, + signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), + endBlock: 4500, } unmarshaled := &signingDoneMessage{} @@ -78,7 +72,7 @@ func TestFuzzSigningDoneMessage_MarshalingRoundtrip(t *testing.T) { senderID group.MemberIndex message big.Int attemptNumber uint64 - signature tecdsa.Signature + signature frost.Signature endBlock uint64 ) diff --git a/pkg/tbtc/moved_funds_sweep_test.go b/pkg/tbtc/moved_funds_sweep_test.go index 68ae7be032..76119a16d5 100644 --- a/pkg/tbtc/moved_funds_sweep_test.go +++ b/pkg/tbtc/moved_funds_sweep_test.go @@ -7,10 +7,9 @@ import ( "testing" "time" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" ) @@ -78,16 +77,15 @@ func TestMovedFundsSweepAction_Execute(t *testing.T) { // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signatures within the scenario fixture are in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack them first. - rawSignatures := make([]*tecdsa.Signature, len(scenario.Signatures)) + // The signatures within the scenario fixture are represented as + // big integer components and need conversion to runtime signature + // containers used by signing executor. + rawSignatures := make([]*frost.Signature, len(scenario.Signatures)) for i, signature := range scenario.Signatures { - rawSignatures[i] = &tecdsa.Signature{ - R: signature.R, - S: signature.S, - } + rawSignatures[i] = mustFrostSignatureFromBigInts( + signature.R, + signature.S, + ) } // Set up the signing executor mock to return the signatures from diff --git a/pkg/tbtc/moving_funds_test.go b/pkg/tbtc/moving_funds_test.go index d1fb2b99d4..42134aec60 100644 --- a/pkg/tbtc/moving_funds_test.go +++ b/pkg/tbtc/moving_funds_test.go @@ -11,8 +11,8 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" - "github.com/keep-network/keep-core/pkg/tecdsa" ) // TODO: Think about covering unhappy paths for specific steps of the moving funds action. @@ -92,14 +92,13 @@ func TestMovingFundsAction_Execute(t *testing.T) { // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signature within the scenario fixture is in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack it first. - rawSignature := &tecdsa.Signature{ - R: scenario.Signature.R, - S: scenario.Signature.S, - } + // The signature within the scenario fixture is represented as + // big integer components and needs conversion to runtime signature + // container used by signing executor. + rawSignature := mustFrostSignatureFromBigInts( + scenario.Signature.R, + scenario.Signature.S, + ) // Set up the signing executor mock to return the signature from // the test fixture when called with the expected parameters. @@ -108,7 +107,7 @@ func TestMovingFundsAction_Execute(t *testing.T) { signingExecutor.setSignatures( []*big.Int{scenario.ExpectedSigHash}, proposalProcessingStartBlock+movingFundsCommitmentConfirmationBlocks, - []*tecdsa.Signature{rawSignature}, + []*frost.Signature{rawSignature}, ) action := newMovingFundsAction( diff --git a/pkg/tbtc/native_tbtc_signer_build_taproot_tx_default.go b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_default.go new file mode 100644 index 0000000000..cf6334056e --- /dev/null +++ b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_default.go @@ -0,0 +1,13 @@ +//go:build !(frost_native && frost_tbtc_signer && cgo) + +package tbtc + +import "github.com/keep-network/keep-core/pkg/bitcoin" + +// buildTaprootTxViaNativeSigner is a no-op on builds that do not link the +// native tbtc-signer bridge. +func buildTaprootTxViaNativeSigner( + unsignedTx *bitcoin.TransactionBuilder, +) (string, error) { + return "", nil +} diff --git a/pkg/tbtc/native_tbtc_signer_build_taproot_tx_frost_native_tbtc_signer.go b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_frost_native_tbtc_signer.go new file mode 100644 index 0000000000..30658c0715 --- /dev/null +++ b/pkg/tbtc/native_tbtc_signer_build_taproot_tx_frost_native_tbtc_signer.go @@ -0,0 +1,108 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package tbtc + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/bitcoin" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func buildTaprootTxViaNativeSigner( + unsignedTx *bitcoin.TransactionBuilder, +) (string, error) { + if unsignedTx == nil { + return "", fmt.Errorf("unsigned transaction builder is nil") + } + + inputs, outputs, err := unsignedTx.UnsignedTransactionIO() + if err != nil { + return "", fmt.Errorf("cannot extract unsigned transaction I/O: [%w]", err) + } + + nativeInputs := make([]frostsigning.NativeTBTCSignerTxInput, 0, len(inputs)) + for _, input := range inputs { + nativeInputs = append( + nativeInputs, + frostsigning.NativeTBTCSignerTxInput{ + TxIDHex: input.TxIDHex, + Vout: input.Vout, + ValueSats: input.ValueSats, + }, + ) + } + + nativeOutputs := make([]frostsigning.NativeTBTCSignerTxOutput, 0, len(outputs)) + for _, output := range outputs { + nativeOutputs = append( + nativeOutputs, + frostsigning.NativeTBTCSignerTxOutput{ + ScriptPubKeyHex: output.ScriptPubKeyHex, + ValueSats: output.ValueSats, + }, + ) + } + + sessionID := buildTaprootTxSessionID(inputs, outputs) + + result, err := frostsigning.BuildNativeTBTCSignerTaprootTx( + sessionID, + nativeInputs, + nativeOutputs, + nil, + ) + if err != nil { + // Keep legacy fallback behavior for the observational BuildTaprootTx + // phase when native bridge support is unavailable. + if errors.Is(err, frostsigning.ErrNativeCryptographyUnavailable) { + return "", nil + } + + return "", err + } + + if result == nil { + return "", fmt.Errorf("native tbtc-signer returned nil BuildTaprootTx result") + } + + if result.SessionID != sessionID { + return "", fmt.Errorf( + "native tbtc-signer BuildTaprootTx returned unexpected session ID: [%v] != [%v]", + result.SessionID, + sessionID, + ) + } + + if result.TxHex == "" { + return "", fmt.Errorf("native tbtc-signer BuildTaprootTx returned empty tx hex") + } + + return result.TxHex, nil +} + +func buildTaprootTxSessionID( + inputs []bitcoin.UnsignedTransactionInput, + outputs []bitcoin.UnsignedTransactionOutput, +) string { + // Session ID is deterministically derived from Go-side transaction I/O using + // encoding/json. Rust currently treats this session_id as opaque. + // If input/output schema changes in a future migration phase, update this + // derivation intentionally to avoid silent cross-version session ID drift. + sessionPayload, err := json.Marshal(struct { + Inputs []bitcoin.UnsignedTransactionInput `json:"inputs"` + Outputs []bitcoin.UnsignedTransactionOutput `json:"outputs"` + }{ + Inputs: inputs, + Outputs: outputs, + }) + if err != nil { + return fmt.Sprintf("buildtx-fallback-%d-%d", len(inputs), len(outputs)) + } + + digest := sha256.Sum256(sessionPayload) + return fmt.Sprintf("buildtx-%x", digest[:]) +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index f8f40b9f7c..03801a4e72 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -17,12 +17,12 @@ import ( "go.uber.org/zap" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/announcer" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/protocol/inactivity" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) const ( @@ -136,6 +136,17 @@ func newNode( proposalGenerator CoordinationProposalGenerator, config Config, ) (*node, error) { + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + return nil, fmt.Errorf( + "cannot register signer material resolver for build: %w", + err, + ) + } + + if err := configureFrostSigningBackend(config); err != nil { + return nil, fmt.Errorf("cannot configure FROST signing backend: %w", err) + } + walletRegistry, err := newWalletRegistry( keyStorePersistance, chain.CalculateWalletID, @@ -196,6 +207,10 @@ func newNode( return node, nil } +func configureFrostSigningBackend(config Config) error { + return signing.SetExecutionBackendByName(config.FrostSigningBackend) +} + // setPerformanceMetrics sets the performance metrics recorder for the node // and wires it into components that support metrics. func (n *node) setPerformanceMetrics(metrics interface { @@ -205,6 +220,25 @@ func (n *node) setPerformanceMetrics(metrics interface { }) { n.performanceMetrics = metrics + if metrics == nil { + signing.UnregisterNativeTBTCSignerFallbackObserver() + } else { + err := signing.RegisterNativeTBTCSignerFallbackObserver( + func(event signing.NativeTBTCSignerFallbackEvent) { + metrics.IncrementCounter( + clientinfo.MetricSigningNativeTBTCSignerFallbackTotal, + 1, + ) + }, + ) + if err != nil { + logger.Warnf( + "cannot register native tbtc-signer fallback observer: [%v]", + err, + ) + } + } + // Initialize window metrics tracker with performance metrics // Keep metrics for the last 100 windows (approximately 25 hours at 900 blocks per window) if perfMetrics, ok := metrics.(clientinfo.PerformanceMetricsRecorder); ok { @@ -1278,22 +1312,50 @@ func (n *node) archiveClosedWallets() error { for _, walletPublicKey := range walletPublicKeys { walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) - walletID, err := n.chain.CalculateWalletID(walletPublicKey) + var walletID [32]byte + var ecdsaWalletID [32]byte + + walletChainData, err := n.chain.GetWallet(walletPublicKeyHash) if err != nil { - return fmt.Errorf( - "could not calculate wallet ID for wallet with public key "+ - "hash [0x%x]: [%v]", - walletPublicKeyHash, - err, - ) + walletID, err = n.chain.CalculateWalletID(walletPublicKey) + if err != nil { + return fmt.Errorf( + "could not resolve wallet IDs for wallet with public key "+ + "hash [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + // Legacy fallback for deployments where canonical wallet lookup + // is unavailable. + ecdsaWalletID = walletID + } else { + walletID = walletChainData.WalletID + if walletID == [32]byte{} { + walletID = DeriveLegacyWalletID(walletPublicKeyHash) + } + + ecdsaWalletID = walletChainData.EcdsaWalletID + if ecdsaWalletID == [32]byte{} { + ecdsaWalletID, err = n.chain.CalculateWalletID(walletPublicKey) + if err != nil { + return fmt.Errorf( + "could not calculate ECDSA wallet ID for wallet with public key "+ + "hash [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + } } - isRegistered, err := n.chain.IsWalletRegistered(walletID) + isRegistered, err := n.chain.IsWalletRegistered(ecdsaWalletID) if err != nil { return fmt.Errorf( - "could not check if wallet is registered for wallet with ID "+ + "could not check if wallet is registered for wallet with ECDSA ID "+ "[0x%x]: [%v]", - walletPublicKeyHash, + ecdsaWalletID, err, ) } @@ -1365,20 +1427,47 @@ func (n *node) handleWalletClosure(walletID [32]byte) error { return fmt.Errorf("wallet closure not confirmed") } - wallet, ok := n.walletRegistry.getWalletByID(walletID) + walletPublicKeyHash, err := n.chain.WalletPublicKeyHashForWalletID(walletID) + if err != nil { + // WalletClosed events still carry ECDSA wallet IDs from the legacy + // registry path. Until closure events are emitted with canonical IDs, + // canonical wallet-ID resolution is expected to miss and we use the + // local registry fallback below. + logger.Debugf( + "cannot resolve wallet public key hash for wallet ID [0x%x]: [%v]; "+ + "falling back to local wallet ID matching", + walletID, + err, + ) + + wallet, ok := n.walletRegistry.getWalletByID(walletID) + if !ok { + // Wallet was not found in the registry. The wallet is not controlled + // by this node. + logger.Infof( + "node does not control wallet with ID [0x%x]; quitting wallet "+ + "archiving", + walletID, + ) + return nil + } + + walletPublicKeyHash = bitcoin.PublicKeyHash(wallet.publicKey) + } + + _, ok := n.walletRegistry.getWalletByPublicKeyHash(walletPublicKeyHash) if !ok { // Wallet was not found in the registry. The wallet is not controlled by // this node. logger.Infof( - "node does not control wallet with ID [0x%x]; quitting wallet "+ - "archiving", + "node does not control wallet with ID [0x%x] and public key hash "+ + "[0x%x]; quitting wallet archiving", walletID, + walletPublicKeyHash, ) return nil } - walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) - err = n.walletRegistry.archiveWallet(walletPublicKeyHash) if err != nil { return fmt.Errorf("failed to archive the wallet: [%v]", err) @@ -1429,6 +1518,8 @@ func withCancelOnBlock( block uint64, waitForBlockFn waitForBlockFn, ) (context.Context, context.CancelFunc) { + // #nosec G118 -- The returned cancel function is intentionally propagated + // to the caller and also invoked by the helper goroutine below. blockCtx, cancelBlockCtx := context.WithCancel(ctx) go func() { diff --git a/pkg/tbtc/node_signing_backend_test.go b/pkg/tbtc/node_signing_backend_test.go new file mode 100644 index 0000000000..b652dad140 --- /dev/null +++ b/pkg/tbtc/node_signing_backend_test.go @@ -0,0 +1,136 @@ +package tbtc + +import ( + "context" + "errors" + "testing" + + "github.com/ipfs/go-log/v2" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/net" +) + +type noopNativeExecutionAdapter struct{} + +func (nnea *noopNativeExecutionAdapter) Execute( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.Request, +) (*frostsigning.Result, error) { + return nil, nil +} + +func (nnea *noopNativeExecutionAdapter) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func TestConfigureFrostSigningBackend_Default(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := configureFrostSigningBackend(Config{}) + if err != nil { + t.Fatalf("unexpected config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.LegacyExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.LegacyExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestConfigureFrostSigningBackend_NativeUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err == nil { + t.Fatal("expected native backend config error") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestConfigureFrostSigningBackend_FFIUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err == nil { + t.Fatal("expected ffi backend config error") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestConfigureFrostSigningBackend_NativeRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestConfigureFrostSigningBackend_FFIRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected ffi backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} diff --git a/pkg/tbtc/node_startup_signing_backend_test.go b/pkg/tbtc/node_startup_signing_backend_test.go new file mode 100644 index 0000000000..4162814113 --- /dev/null +++ b/pkg/tbtc/node_startup_signing_backend_test.go @@ -0,0 +1,200 @@ +package tbtc + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/generator" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/net/local" +) + +func TestNewNode_ConfiguresFrostSigningBackend_NativeUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + _, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "native"}, + ) + if err == nil { + t.Fatal("expected newNode startup error for unavailable native backend") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected newNode startup error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestNewNode_ConfiguresFrostSigningBackend_FFIUnavailable(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + _, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "ffi"}, + ) + if err == nil { + t.Fatal("expected newNode startup error for unavailable ffi backend") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected newNode startup error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } +} + +func TestNewNode_ConfiguresFrostSigningBackend_NativeRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + node, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "native"}, + ) + if err != nil { + t.Fatalf("unexpected newNode startup error: [%v]", err) + } + + if node == nil { + t.Fatal("expected node instance") + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestNewNode_ConfiguresFrostSigningBackend_FFIRegistered(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + + err := frostsigning.RegisterNativeExecutionAdapter(&noopNativeExecutionAdapter{}) + if err != nil { + t.Fatalf("unexpected native adapter registration error: [%v]", err) + } + + groupParameters, localChain, netProvider, keyStorePersistence := + setupNewNodeSigningBackendTestDependencies(t) + + node, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + netProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{FrostSigningBackend: "ffi"}, + ) + if err != nil { + t.Fatalf("unexpected newNode startup error: [%v]", err) + } + + if node == nil { + t.Fatal("expected node instance") + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func setupNewNodeSigningBackendTestDependencies( + t *testing.T, +) ( + *GroupParameters, + Chain, + net.Provider, + *mockPersistenceHandle, +) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + localChain := Connect() + netProvider := local.Connect() + signer := createMockSigner(t) + + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signer.wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + State: StateLive, + }, + ) + + return groupParameters, + localChain, + netProvider, + createMockKeyStorePersistence(t, signer) +} diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index bedfb30995..967cb79ece 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -100,9 +100,7 @@ func TestNode_GetSigningExecutor(t *testing.T) { len(executor.signers), ) - if !reflect.DeepEqual(signer, executor.signers[0]) { - t.Errorf("executor holds an unexpected signer") - } + assertSignerEquivalent(t, "executor signer", signer, executor.signers[0]) expectedChannel := fmt.Sprintf( "%s-%s", @@ -491,6 +489,7 @@ func createMockSigner(t *testing.T) *signer { }, signingGroupMemberIndex: group.MemberIndex(1), privateKeyShare: privateKeyShare, + signerMaterial: privateKeyShare, } } diff --git a/pkg/tbtc/redemption_test.go b/pkg/tbtc/redemption_test.go index 0a6897dd94..b2c35b8cb9 100644 --- a/pkg/tbtc/redemption_test.go +++ b/pkg/tbtc/redemption_test.go @@ -6,12 +6,11 @@ import ( "testing" "time" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/go-test/deep" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" ) @@ -104,14 +103,13 @@ func TestRedemptionAction_Execute(t *testing.T) { // Create a signing executor mock instance. signingExecutor := newMockWalletSigningExecutor() - // The signature within the scenario fixture is in the format - // suitable for applying them directly to a Bitcoin transaction. - // However, the signing executor operates on raw tECDSA signatures - // so, we need to unpack it first. - rawSignature := &tecdsa.Signature{ - R: scenario.Signature.R, - S: scenario.Signature.S, - } + // The signature within the scenario fixture is represented as + // big integer components and needs conversion to runtime signature + // container used by signing executor. + rawSignature := mustFrostSignatureFromBigInts( + scenario.Signature.R, + scenario.Signature.S, + ) // Set up the signing executor mock to return the signature from // the test fixture when called with the expected parameters. @@ -120,7 +118,7 @@ func TestRedemptionAction_Execute(t *testing.T) { signingExecutor.setSignatures( []*big.Int{scenario.ExpectedSigHash}, proposalProcessingStartBlock, - []*tecdsa.Signature{rawSignature}, + []*frost.Signature{rawSignature}, ) action := newRedemptionAction( diff --git a/pkg/tbtc/registry_test.go b/pkg/tbtc/registry_test.go index f0d4964ce1..ae5a7ed589 100644 --- a/pkg/tbtc/registry_test.go +++ b/pkg/tbtc/registry_test.go @@ -283,9 +283,12 @@ func TestWalletRegistry_PrePopulateWalletCache(t *testing.T) { len(walletRegistry.walletCache[walletStorageKey].signers), ) - if !reflect.DeepEqual(signer, walletRegistry.walletCache[walletStorageKey].signers[0]) { - t.Errorf("loaded wallet signer differs from the original one") - } + assertSignerEquivalent( + t, + "pre-populated wallet signer", + signer, + walletRegistry.walletCache[walletStorageKey].signers[0], + ) } func TestWalletRegistry_GetWalletsPublicKeys(t *testing.T) { @@ -459,9 +462,12 @@ func TestWalletStorage_LoadSigners(t *testing.T) { len(signersByWallet[walletStorageKey]), ) - if !reflect.DeepEqual(signer, signersByWallet[walletStorageKey][0]) { - t.Errorf("loaded wallet signer differs from the original one") - } + assertSignerEquivalent( + t, + "loaded wallet signer", + signer, + signersByWallet[walletStorageKey][0], + ) } func TestWalletStorage_ArchiveWallet(t *testing.T) { diff --git a/pkg/tbtc/signature_test_helpers_test.go b/pkg/tbtc/signature_test_helpers_test.go new file mode 100644 index 0000000000..b4019893d0 --- /dev/null +++ b/pkg/tbtc/signature_test_helpers_test.go @@ -0,0 +1,23 @@ +package tbtc + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/frost" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func mustFrostSignatureFromBigInts(r *big.Int, s *big.Int) *frost.Signature { + return mustFrostSignatureFromTECDSA(&tecdsa.Signature{R: r, S: s}) +} + +func mustFrostSignatureFromTECDSA(signature *tecdsa.Signature) *frost.Signature { + result, err := frostsigning.FromTECDSASignature(signature) + if err != nil { + panic(fmt.Sprintf("signature conversion failed: %v", err)) + } + + return result +} diff --git a/pkg/tbtc/signer_equivalence_test.go b/pkg/tbtc/signer_equivalence_test.go new file mode 100644 index 0000000000..382ba85bd2 --- /dev/null +++ b/pkg/tbtc/signer_equivalence_test.go @@ -0,0 +1,82 @@ +package tbtc + +import ( + "bytes" + "reflect" + "testing" +) + +func assertSignerEquivalent( + t *testing.T, + name string, + expected *signer, + actual *signer, +) { + t.Helper() + + if expected == nil { + if actual != nil { + t.Fatalf("%s should be nil", name) + } + return + } + + if actual == nil { + t.Fatalf("%s is nil", name) + } + + if !expected.wallet.publicKey.Equal(actual.wallet.publicKey) { + t.Fatalf("%s has unexpected wallet public key", name) + } + + if !reflect.DeepEqual( + expected.wallet.signingGroupOperators, + actual.wallet.signingGroupOperators, + ) { + t.Fatalf( + "%s has unexpected signing group operators\nexpected: [%v]\nactual: [%v]", + name, + expected.wallet.signingGroupOperators, + actual.wallet.signingGroupOperators, + ) + } + + if expected.signingGroupMemberIndex != actual.signingGroupMemberIndex { + t.Fatalf( + "%s has unexpected member index\nexpected: [%v]\nactual: [%v]", + name, + expected.signingGroupMemberIndex, + actual.signingGroupMemberIndex, + ) + } + + if expected.privateKeyShare == nil { + if actual.privateKeyShare != nil { + t.Fatalf("%s should have nil private key share", name) + } + return + } + + if actual.privateKeyShare == nil { + t.Fatalf("%s has nil private key share", name) + } + + expectedPrivateKeyShare, err := expected.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("cannot marshal expected private key share for %s: [%v]", name, err) + } + + actualPrivateKeyShare, err := actual.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("cannot marshal actual private key share for %s: [%v]", name, err) + } + + if !bytes.Equal(expectedPrivateKeyShare, actualPrivateKeyShare) { + t.Fatalf( + "%s has unexpected private key share\nexpected: [%x]\nactual: [%x]", + name, + expectedPrivateKeyShare, + actualPrivateKeyShare, + ) + } +} diff --git a/pkg/tbtc/signer_material_encoding.go b/pkg/tbtc/signer_material_encoding.go new file mode 100644 index 0000000000..dba6e6e7f4 --- /dev/null +++ b/pkg/tbtc/signer_material_encoding.go @@ -0,0 +1,300 @@ +package tbtc + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +var signerMaterialEnvelopePrefix = []byte("tbtc-signer-material-v1:") + +// signerMaterialMaxFormatLength bounds the length of the format identifier in +// a serialized signer-material envelope. Real format identifiers are short +// labels like "frost-tbtc-signer-v1", so 256 bytes is generous; the cap exists +// to refuse a uvarint-claimed length that would allocate a huge string from a +// hostile or corrupted payload before the existing `offset+int(formatLength) > +// len(data)` bounds check runs. +const signerMaterialMaxFormatLength uint64 = 256 + +// signerMaterialMaxPayloadLength bounds the length of the payload body. JSON +// envelopes for FROST and the tBTC-signer key material carry tens of KiB of +// hex; 256 KiB is comfortably above that and refuses a uvarint-claimed length +// that would allocate hundreds of MiB from a corrupted state file or a +// hostile peer. +const signerMaterialMaxPayloadLength uint64 = 256 * 1024 + +type unmarshaledSignerMaterial struct { + signerMaterial any + privateKeyShare *tecdsa.PrivateKeyShare +} + +func marshalSignerMaterialForPersistence( + signerMaterial any, + fallbackPrivateKeyShare *tecdsa.PrivateKeyShare, +) ([]byte, error) { + if signerMaterial == nil { + signerMaterial = fallbackPrivateKeyShare + } + + switch material := signerMaterial.(type) { + case *tecdsa.PrivateKeyShare: + if material == nil { + return nil, fmt.Errorf("legacy private key share is nil") + } + + return material.Marshal() + case tecdsa.PrivateKeyShare: + materialCopy := material + return (&materialCopy).Marshal() + case *frostsigning.NativeSignerMaterial: + if material == nil { + return nil, fmt.Errorf("native signer material is nil") + } + + return encodeNativeSignerMaterialForPersistence( + material.Format, + material.Payload, + ) + case frostsigning.NativeSignerMaterial: + return encodeNativeSignerMaterialForPersistence( + material.Format, + material.Payload, + ) + case []byte: + // Transitional compatibility: raw bytes are treated as legacy + // frost-uniffi-v1 payloads from previously persisted signer entries. + return encodeNativeSignerMaterialForPersistence( + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + material, + ) + default: + return nil, fmt.Errorf("unsupported signer material type: [%T]", signerMaterial) + } +} + +func unmarshalSignerMaterialFromPersistence( + data []byte, +) (*unmarshaledSignerMaterial, error) { + nativeSignerMaterial, isNative, err := decodeNativeSignerMaterialFromPersistence( + data, + ) + if err != nil { + return nil, err + } + + if isNative { + privateKeyShare := legacyPrivateKeyShareFromNativeSignerMaterial( + nativeSignerMaterial, + ) + + return &unmarshaledSignerMaterial{ + signerMaterial: nativeSignerMaterial, + privateKeyShare: privateKeyShare, + }, nil + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(data); err != nil { + return nil, fmt.Errorf("cannot unmarshal private key share: [%w]", err) + } + + resolvedSignerMaterial, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + return nil, fmt.Errorf( + "cannot resolve signer material from legacy private key share: [%w]", + err, + ) + } + + if resolvedSignerMaterial == nil { + return nil, fmt.Errorf( + "resolved signer material from legacy private key share is nil", + ) + } + + return &unmarshaledSignerMaterial{ + signerMaterial: resolvedSignerMaterial, + privateKeyShare: privateKeyShare, + }, nil +} + +func encodeNativeSignerMaterialForPersistence( + format string, + payload []byte, +) ([]byte, error) { + material := &frostsigning.NativeSignerMaterial{ + Format: format, + Payload: append([]byte{}, payload...), + } + + if err := validateNativeSignerMaterialForPersistence(material); err != nil { + return nil, err + } + + result := make([]byte, 0, len(signerMaterialEnvelopePrefix)+len(format)+len(payload)+20) + result = append(result, signerMaterialEnvelopePrefix...) + + var varintBuffer [binary.MaxVarintLen64]byte + + formatLength := binary.PutUvarint(varintBuffer[:], uint64(len(material.Format))) + result = append(result, varintBuffer[:formatLength]...) + result = append(result, []byte(material.Format)...) + + payloadLength := binary.PutUvarint(varintBuffer[:], uint64(len(material.Payload))) + result = append(result, varintBuffer[:payloadLength]...) + result = append(result, material.Payload...) + + return result, nil +} + +func decodeNativeSignerMaterialFromPersistence( + data []byte, +) ( + *frostsigning.NativeSignerMaterial, + bool, + error, +) { + if !bytes.HasPrefix(data, signerMaterialEnvelopePrefix) { + return nil, false, nil + } + + offset := len(signerMaterialEnvelopePrefix) + + formatLength, lengthBytes, err := readPersistenceUvarint(data, offset) + if err != nil { + return nil, true, fmt.Errorf("invalid signer material format length: [%w]", err) + } + if formatLength > signerMaterialMaxFormatLength { + return nil, true, fmt.Errorf( + "signer material format length %d exceeds maximum %d", + formatLength, + signerMaterialMaxFormatLength, + ) + } + offset += lengthBytes + + if offset+int(formatLength) > len(data) { + return nil, true, fmt.Errorf("signer material format length exceeds payload") + } + + format := string(data[offset : offset+int(formatLength)]) + offset += int(formatLength) + + payloadLength, lengthBytes, err := readPersistenceUvarint(data, offset) + if err != nil { + return nil, true, fmt.Errorf("invalid signer material payload length: [%w]", err) + } + if payloadLength > signerMaterialMaxPayloadLength { + return nil, true, fmt.Errorf( + "signer material payload length %d exceeds maximum %d", + payloadLength, + signerMaterialMaxPayloadLength, + ) + } + offset += lengthBytes + + if offset+int(payloadLength) > len(data) { + return nil, true, fmt.Errorf("signer material payload length exceeds payload") + } + + payload := append([]byte{}, data[offset:offset+int(payloadLength)]...) + offset += int(payloadLength) + + if offset != len(data) { + return nil, true, fmt.Errorf("unexpected trailing signer material payload bytes") + } + + material := &frostsigning.NativeSignerMaterial{ + Format: format, + Payload: payload, + } + + if err := validateNativeSignerMaterialForPersistence(material); err != nil { + return nil, true, err + } + + return material, true, nil +} + +func validateNativeSignerMaterialForPersistence( + material *frostsigning.NativeSignerMaterial, +) error { + if material == nil { + return fmt.Errorf("native signer material is nil") + } + + if material.Format == "" { + return fmt.Errorf("native signer material format is empty") + } + + if len(material.Payload) == 0 { + return fmt.Errorf("native signer material payload is empty") + } + + return nil +} + +func readPersistenceUvarint(data []byte, offset int) (uint64, int, error) { + if offset >= len(data) { + return 0, 0, fmt.Errorf("offset [%d] out of bounds", offset) + } + + value, lengthBytes := binary.Uvarint(data[offset:]) + if lengthBytes == 0 { + return 0, 0, fmt.Errorf("incomplete uvarint") + } + + if lengthBytes < 0 { + return 0, 0, fmt.Errorf("overflowed uvarint") + } + + return value, lengthBytes, nil +} + +func legacyPrivateKeyShareFromNativeSignerMaterial( + nativeSignerMaterial *frostsigning.NativeSignerMaterial, +) *tecdsa.PrivateKeyShare { + if nativeSignerMaterial == nil { + return nil + } + + switch nativeSignerMaterial.Format { + case frostsigning.NativeSignerMaterialFormatFrostUniFFIV1: + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(nativeSignerMaterial.Payload); err != nil { + return nil + } + + return privateKeyShare + + case frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1: + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(nativeSignerMaterial.Payload, &payload); err != nil { + return nil + } + + if payload.LegacyPrivateKeyShareHex == "" { + return nil + } + + legacyPayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + return nil + } + + privateKeyShare := &tecdsa.PrivateKeyShare{} + if err := privateKeyShare.Unmarshal(legacyPayload); err != nil { + return nil + } + + return privateKeyShare + + default: + return nil + } +} diff --git a/pkg/tbtc/signer_material_encoding_default_build_test.go b/pkg/tbtc/signer_material_encoding_default_build_test.go new file mode 100644 index 0000000000..031d28477e --- /dev/null +++ b/pkg/tbtc/signer_material_encoding_default_build_test.go @@ -0,0 +1,52 @@ +//go:build !frost_native + +package tbtc + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestUnmarshalSignerMaterialFromPersistence_LegacyEncoding_DefaultBuildReturnsLegacySignerMaterial( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + legacyEncoded, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling legacy private key share: [%v]", err) + } + + unmarshaledSignerMaterial, err := unmarshalSignerMaterialFromPersistence( + legacyEncoded, + ) + if err != nil { + t.Fatalf("unexpected unmarshal error: [%v]", err) + } + + if unmarshaledSignerMaterial.privateKeyShare == nil { + t.Fatal("expected private key share") + } + + resolvedPrivateKeyShare, ok := unmarshaledSignerMaterial.signerMaterial.(*tecdsa.PrivateKeyShare) + if !ok { + t.Fatalf( + "unexpected signer material type\nexpected: [%T]\nactual: [%T]", + &tecdsa.PrivateKeyShare{}, + unmarshaledSignerMaterial.signerMaterial, + ) + } + + if resolvedPrivateKeyShare != unmarshaledSignerMaterial.privateKeyShare { + t.Fatal("expected signer material to reference recovered private key share") + } +} diff --git a/pkg/tbtc/signer_material_encoding_frost_native_test.go b/pkg/tbtc/signer_material_encoding_frost_native_test.go new file mode 100644 index 0000000000..e6bcbd8caf --- /dev/null +++ b/pkg/tbtc/signer_material_encoding_frost_native_test.go @@ -0,0 +1,155 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" + "github.com/keep-network/keep-core/pkg/tecdsa" + "google.golang.org/protobuf/proto" +) + +func TestUnmarshalSignerMaterialFromPersistence_LegacyEncodingResolvesNativeMaterialOnFrostNativeBuild( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + legacyEncoded, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling legacy private key share: [%v]", err) + } + + unmarshaledSignerMaterial, err := unmarshalSignerMaterialFromPersistence( + legacyEncoded, + ) + if err != nil { + t.Fatalf("unexpected unmarshal error: [%v]", err) + } + + if unmarshaledSignerMaterial.privateKeyShare == nil { + t.Fatal("expected legacy private key share to be preserved") + } + + nativeSignerMaterial, ok := unmarshaledSignerMaterial.signerMaterial.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + unmarshaledSignerMaterial.signerMaterial, + ) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + nativeSignerMaterial.Format, + ) + } + + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(nativeSignerMaterial.Payload, &payload); err != nil { + t.Fatalf("failed unmarshalling tbtc signer material payload: [%v]", err) + } + + if payload.KeyGroup == "" { + t.Fatal("expected non-empty tbtc-signer key group") + } + + if payload.KeyGroupSource == "" { + t.Fatal("expected non-empty tbtc-signer key group source") + } + + legacyPrivateKeySharePayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + t.Fatalf("failed decoding legacy private key share payload: [%v]", err) + } + + decodedPrivateKeyShare := &tecdsa.PrivateKeyShare{} + if err := decodedPrivateKeyShare.Unmarshal(legacyPrivateKeySharePayload); err != nil { + t.Fatalf("failed unmarshalling decoded private key share: [%v]", err) + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(actualPayload, legacyEncoded) { + t.Fatalf( + "unexpected resolved signer payload\nexpected: [%x]\nactual: [%x]", + legacyEncoded, + actualPayload, + ) + } +} + +func TestSignerMarshalling_LegacyRoundtripMigratesToNativeEnvelopeOnFrostNativeBuild( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + if err := RegisterSignerMaterialResolverForBuild(); err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + legacySigner := createMockSigner(t) + legacySigner.signerMaterial = legacySigner.privateKeyShare + + initialEncodedSigner, err := legacySigner.Marshal() + if err != nil { + t.Fatalf("unexpected initial signer marshal error: [%v]", err) + } + + initialPBSigner := &pb.Signer{} + if err := proto.Unmarshal(initialEncodedSigner, initialPBSigner); err != nil { + t.Fatalf("unexpected initial proto unmarshal error: [%v]", err) + } + + if bytes.HasPrefix(initialPBSigner.PrivateKeyShare, signerMaterialEnvelopePrefix) { + t.Fatal("expected initial legacy signer encoding without native envelope") + } + + unmarshaledSigner := &signer{} + if err := unmarshaledSigner.Unmarshal(initialEncodedSigner); err != nil { + t.Fatalf("unexpected signer unmarshal error: [%v]", err) + } + + if _, ok := unmarshaledSigner.signerMaterial.(*frostsigning.NativeSignerMaterial); !ok { + t.Fatalf( + "unexpected signer material type after legacy unmarshal\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + unmarshaledSigner.signerMaterial, + ) + } + + migratedEncodedSigner, err := unmarshaledSigner.Marshal() + if err != nil { + t.Fatalf("unexpected migrated signer marshal error: [%v]", err) + } + + migratedPBSigner := &pb.Signer{} + if err := proto.Unmarshal(migratedEncodedSigner, migratedPBSigner); err != nil { + t.Fatalf("unexpected migrated proto unmarshal error: [%v]", err) + } + + if !bytes.HasPrefix(migratedPBSigner.PrivateKeyShare, signerMaterialEnvelopePrefix) { + t.Fatal("expected migrated signer encoding with native envelope prefix") + } +} diff --git a/pkg/tbtc/signer_material_encoding_test.go b/pkg/tbtc/signer_material_encoding_test.go new file mode 100644 index 0000000000..fdef4ccb34 --- /dev/null +++ b/pkg/tbtc/signer_material_encoding_test.go @@ -0,0 +1,335 @@ +package tbtc + +import ( + "bytes" + "encoding/binary" + "reflect" + "strings" + "testing" + + "github.com/google/gofuzz" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/internal/pbutils" + "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" + "github.com/keep-network/keep-core/pkg/tecdsa" + "google.golang.org/protobuf/proto" +) + +// appendUvarintForTest emits a uvarint matching the format +// `unmarshalSignerMaterialFromPersistence` expects. It is duplicated in the +// test package rather than exported so test-only construction of corrupted +// envelopes cannot accidentally be reused by production code. +func appendUvarintForTest(buf []byte, value uint64) []byte { + var scratch [binary.MaxVarintLen64]byte + n := binary.PutUvarint(scratch[:], value) + return append(buf, scratch[:n]...) +} + +func TestMarshalSignerMaterialForPersistence_LegacyPrivateKeyShare(t *testing.T) { + signer := createMockSigner(t) + + encoded, err := marshalSignerMaterialForPersistence( + signer.privateKeyShare, + nil, + ) + if err != nil { + t.Fatalf("unexpected marshal error: [%v]", err) + } + + _, isNative, err := decodeNativeSignerMaterialFromPersistence(encoded) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if isNative { + t.Fatal("expected legacy private key share encoding") + } + + decoded := &tecdsa.PrivateKeyShare{} + if err := decoded.Unmarshal(encoded); err != nil { + t.Fatalf("unexpected legacy unmarshal error: [%v]", err) + } +} + +func TestMarshalSignerMaterialForPersistence_NativeSignerMaterial(t *testing.T) { + payload := []byte{0xaa, 0xbb, 0xcc} + encoded, err := marshalSignerMaterialForPersistence( + &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + Payload: payload, + }, + nil, + ) + if err != nil { + t.Fatalf("unexpected marshal error: [%v]", err) + } + + decoded, isNative, err := decodeNativeSignerMaterialFromPersistence(encoded) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + + if !isNative { + t.Fatal("expected native signer material envelope") + } + + if decoded == nil { + t.Fatal("expected native signer material") + } + + if decoded.Format != frostsigning.NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected decoded format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + decoded.Format, + ) + } + + if !bytes.Equal(decoded.Payload, payload) { + t.Fatalf( + "unexpected decoded payload\nexpected: [%x]\nactual: [%x]", + payload, + decoded.Payload, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_NativeEnvelope(t *testing.T) { + signer := createMockSigner(t) + payload, err := signer.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("unexpected private key share marshal error: [%v]", err) + } + + encoded, err := encodeNativeSignerMaterialForPersistence( + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + payload, + ) + if err != nil { + t.Fatalf("unexpected encode error: [%v]", err) + } + + decoded, err := unmarshalSignerMaterialFromPersistence(encoded) + if err != nil { + t.Fatalf("unexpected unmarshal error: [%v]", err) + } + + if decoded.privateKeyShare == nil { + t.Fatal("expected legacy private key share recovery from native signer material") + } + + recoveredPayload, err := decoded.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("unexpected recovered private key share marshal error: [%v]", err) + } + + if !bytes.Equal(recoveredPayload, payload) { + t.Fatalf( + "unexpected recovered private key share\nexpected: [%x]\nactual: [%x]", + payload, + recoveredPayload, + ) + } + + nativeSignerMaterial, ok := decoded.signerMaterial.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + decoded.signerMaterial, + ) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + nativeSignerMaterial.Format, + ) + } + + if !bytes.Equal(nativeSignerMaterial.Payload, payload) { + t.Fatalf( + "unexpected signer material payload\nexpected: [%x]\nactual: [%x]", + payload, + nativeSignerMaterial.Payload, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_CorruptedNativeEnvelope(t *testing.T) { + encoded, err := encodeNativeSignerMaterialForPersistence( + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + []byte{0x10, 0x20}, + ) + if err != nil { + t.Fatalf("unexpected encode error: [%v]", err) + } + + encoded = encoded[:len(encoded)-1] + + _, err = unmarshalSignerMaterialFromPersistence(encoded) + if err == nil { + t.Fatal("expected unmarshal error") + } + + if !strings.Contains(err.Error(), "signer material payload length exceeds payload") { + t.Fatalf( + "unexpected unmarshal error\nexpected substring: [%s]\nactual: [%v]", + "signer material payload length exceeds payload", + err, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_RejectsOversizedFormatLength( + t *testing.T, +) { + // Build an envelope that claims a format length one byte above the cap. + // The body itself is short, so without the length cap the bounds check + // would still catch this, but the cap rejects the claim earlier and with + // a clear error before any allocation. + encoded := append([]byte{}, signerMaterialEnvelopePrefix...) + encoded = appendUvarintForTest(encoded, signerMaterialMaxFormatLength+1) + encoded = append(encoded, []byte("ignored")...) + + _, err := unmarshalSignerMaterialFromPersistence(encoded) + if err == nil { + t.Fatal("expected unmarshal error") + } + + if !strings.Contains(err.Error(), "format length") || + !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf( + "unexpected unmarshal error\nexpected substrings: [format length], [exceeds maximum]\nactual: [%v]", + err, + ) + } +} + +func TestUnmarshalSignerMaterialFromPersistence_RejectsOversizedPayloadLength( + t *testing.T, +) { + encoded := append([]byte{}, signerMaterialEnvelopePrefix...) + format := []byte(frostsigning.NativeSignerMaterialFormatFrostUniFFIV1) + encoded = appendUvarintForTest(encoded, uint64(len(format))) + encoded = append(encoded, format...) + encoded = appendUvarintForTest(encoded, signerMaterialMaxPayloadLength+1) + + _, err := unmarshalSignerMaterialFromPersistence(encoded) + if err == nil { + t.Fatal("expected unmarshal error") + } + + if !strings.Contains(err.Error(), "payload length") || + !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf( + "unexpected unmarshal error\nexpected substrings: [payload length], [exceeds maximum]\nactual: [%v]", + err, + ) + } +} + +func TestMarshalSignerMaterialForPersistence_UnsupportedType(t *testing.T) { + _, err := marshalSignerMaterialForPersistence(struct{}{}, nil) + if err == nil { + t.Fatal("expected marshal error") + } + + if !strings.Contains(err.Error(), "unsupported signer material type") { + t.Fatalf( + "unexpected marshal error\nexpected substring: [%s]\nactual: [%v]", + "unsupported signer material type", + err, + ) + } +} + +func TestSignerMarshalling_NativeSignerMaterialRoundtrip(t *testing.T) { + legacySigner := createMockSigner(t) + marshaled := &signer{ + wallet: legacySigner.wallet, + signingGroupMemberIndex: legacySigner.signingGroupMemberIndex, + signerMaterial: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x44, 0x55, 0x66}, + }, + } + unmarshaled := &signer{} + + if err := pbutils.RoundTrip(marshaled, unmarshaled); err != nil { + t.Fatal(err) + } + + if unmarshaled.privateKeyShare != nil { + t.Fatal("expected nil private key share for native signer material") + } + + if !reflect.DeepEqual(marshaled.wallet, unmarshaled.wallet) { + t.Fatalf( + "unexpected wallet state after roundtrip\nexpected: [%+v]\nactual: [%+v]", + marshaled.wallet, + unmarshaled.wallet, + ) + } + + if marshaled.signingGroupMemberIndex != unmarshaled.signingGroupMemberIndex { + t.Fatalf( + "unexpected signer member index\nexpected: [%v]\nactual: [%v]", + marshaled.signingGroupMemberIndex, + unmarshaled.signingGroupMemberIndex, + ) + } + + nativeSignerMaterial, ok := unmarshaled.signerMaterial.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + unmarshaled.signerMaterial, + ) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostUniFFIV1 { + t.Fatalf( + "unexpected signer material format\nexpected: [%v]\nactual: [%v]", + frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + nativeSignerMaterial.Format, + ) + } + + if !bytes.Equal(nativeSignerMaterial.Payload, []byte{0x44, 0x55, 0x66}) { + t.Fatalf( + "unexpected signer material payload\nexpected: [%x]\nactual: [%x]", + []byte{0x44, 0x55, 0x66}, + nativeSignerMaterial.Payload, + ) + } +} + +func TestSignerMarshalling_LegacyEncodingDoesNotUseNativeEnvelope(t *testing.T) { + signer := createMockSigner(t) + + encodedSigner, err := signer.Marshal() + if err != nil { + t.Fatalf("unexpected marshal error: [%v]", err) + } + + pbSigner := &pb.Signer{} + if err := proto.Unmarshal(encodedSigner, pbSigner); err != nil { + t.Fatalf("unexpected proto unmarshal error: [%v]", err) + } + + if bytes.HasPrefix(pbSigner.PrivateKeyShare, signerMaterialEnvelopePrefix) { + t.Fatal("expected legacy signer encoding without native envelope") + } +} + +func TestFuzzDecodeNativeSignerMaterialFromPersistence(t *testing.T) { + for i := 0; i < 10; i++ { + var data []byte + fuzz.New().NilChance(0.1).NumElements(0, 256).Fuzz(&data) + + _, _, _ = decodeNativeSignerMaterialFromPersistence(data) + } +} diff --git a/pkg/tbtc/signer_material_resolver.go b/pkg/tbtc/signer_material_resolver.go new file mode 100644 index 0000000000..ce9dc08d06 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver.go @@ -0,0 +1,109 @@ +package tbtc + +import ( + "fmt" + "sync" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +// SignerMaterialResolver derives signer material from a legacy private key +// share. Implementations can provide backend-native signer material while +// preserving fallback compatibility. +type SignerMaterialResolver interface { + ResolveSignerMaterial(privateKeyShare *tecdsa.PrivateKeyShare) (any, error) +} + +// SignerMaterialResolverProviderForBuild produces a signer material resolver +// bound to the current build/runtime flavor. +type SignerMaterialResolverProviderForBuild func() (SignerMaterialResolver, error) + +type legacyPrivateKeyShareSignerMaterialResolver struct{} + +func (lpkssmr *legacyPrivateKeyShareSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + if privateKeyShare == nil { + return nil, fmt.Errorf("private key share is nil") + } + + return privateKeyShare, nil +} + +var ( + signerMaterialResolverMutex sync.RWMutex + signerMaterialResolver SignerMaterialResolver = &legacyPrivateKeyShareSignerMaterialResolver{} + signerMaterialResolverProviderForBuild SignerMaterialResolverProviderForBuild +) + +// RegisterSignerMaterialResolver registers a signer material resolver used by +// DKG signer construction. +func RegisterSignerMaterialResolver(resolver SignerMaterialResolver) error { + if resolver == nil { + return fmt.Errorf("signer material resolver is nil") + } + + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolver = resolver + + return nil +} + +// UnregisterSignerMaterialResolver restores the default legacy resolver. +func UnregisterSignerMaterialResolver() { + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolver = &legacyPrivateKeyShareSignerMaterialResolver{} +} + +// RegisterSignerMaterialResolverProviderForBuild registers a provider used by +// RegisterSignerMaterialResolverForBuild. +func RegisterSignerMaterialResolverProviderForBuild( + provider SignerMaterialResolverProviderForBuild, +) error { + if provider == nil { + return fmt.Errorf("signer material resolver provider is nil") + } + + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolverProviderForBuild = provider + + return nil +} + +// UnregisterSignerMaterialResolverProviderForBuild clears build-scoped resolver +// provider registration. +func UnregisterSignerMaterialResolverProviderForBuild() { + signerMaterialResolverMutex.Lock() + defer signerMaterialResolverMutex.Unlock() + + signerMaterialResolverProviderForBuild = nil +} + +func currentSignerMaterialResolver() SignerMaterialResolver { + signerMaterialResolverMutex.RLock() + defer signerMaterialResolverMutex.RUnlock() + + return signerMaterialResolver +} + +func currentSignerMaterialResolverProviderForBuild() SignerMaterialResolverProviderForBuild { + signerMaterialResolverMutex.RLock() + defer signerMaterialResolverMutex.RUnlock() + + return signerMaterialResolverProviderForBuild +} + +func resolveSignerMaterial(privateKeyShare *tecdsa.PrivateKeyShare) (any, error) { + resolver := currentSignerMaterialResolver() + if resolver == nil { + return nil, fmt.Errorf("signer material resolver is nil") + } + + return resolver.ResolveSignerMaterial(privateKeyShare) +} diff --git a/pkg/tbtc/signer_material_resolver_build.go b/pkg/tbtc/signer_material_resolver_build.go new file mode 100644 index 0000000000..115bd05b9d --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build.go @@ -0,0 +1,7 @@ +package tbtc + +// RegisterSignerMaterialResolverForBuild attempts to register signer-material +// resolver bindings for the current build flavor. +func RegisterSignerMaterialResolverForBuild() error { + return registerSignerMaterialResolverForBuild() +} diff --git a/pkg/tbtc/signer_material_resolver_build_default.go b/pkg/tbtc/signer_material_resolver_build_default.go new file mode 100644 index 0000000000..a1d8cd7a23 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_default.go @@ -0,0 +1,7 @@ +//go:build !frost_native + +package tbtc + +func registerSignerMaterialResolverForBuild() error { + return nil +} diff --git a/pkg/tbtc/signer_material_resolver_build_frost_native.go b/pkg/tbtc/signer_material_resolver_build_frost_native.go new file mode 100644 index 0000000000..3cef396081 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_frost_native.go @@ -0,0 +1,74 @@ +//go:build frost_native && !(frost_tbtc_signer && cgo) + +package tbtc + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func registerSignerMaterialResolverForBuild() error { + provider := currentSignerMaterialResolverProviderForBuild() + if provider == nil { + provider = defaultSignerMaterialResolverProviderForBuild + } + + resolver, err := provider() + if err != nil { + return err + } + + if resolver == nil { + return fmt.Errorf("signer material resolver is nil") + } + + return RegisterSignerMaterialResolver(resolver) +} + +func defaultSignerMaterialResolverProviderForBuild() (SignerMaterialResolver, error) { + return &buildTaggedNativeSignerMaterialResolver{}, nil +} + +// buildTaggedNativeSignerMaterialResolver derives transitional native signer +// material from a legacy private key share for frost_native builds not using +// the `frost_tbtc_signer` tag. +type buildTaggedNativeSignerMaterialResolver struct{} + +func (btnsmr *buildTaggedNativeSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + if privateKeyShare == nil { + return nil, fmt.Errorf("private key share is nil") + } + + legacyPrivateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + return nil, fmt.Errorf("cannot marshal private key share: [%w]", err) + } + + walletPublicKeyBytes, err := marshalPublicKey(privateKeyShare.PublicKey()) + if err != nil { + return nil, fmt.Errorf("cannot marshal wallet public key: [%w]", err) + } + + keyGroupDigest := sha256.Sum256(walletPublicKeyBytes) + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: hex.EncodeToString(keyGroupDigest[:]), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(legacyPrivateKeySharePayload), + }) + if err != nil { + return nil, fmt.Errorf("cannot marshal tbtc signer material payload: [%w]", err) + } + + return &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, nil +} diff --git a/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer.go b/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer.go new file mode 100644 index 0000000000..268b53a521 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_frost_native_tbtc_signer.go @@ -0,0 +1,91 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package tbtc + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func registerSignerMaterialResolverForBuild() error { + provider := currentSignerMaterialResolverProviderForBuild() + if provider == nil { + provider = defaultSignerMaterialResolverProviderForBuild + } + + resolver, err := provider() + if err != nil { + return err + } + + if resolver == nil { + return fmt.Errorf("signer material resolver is nil") + } + + return RegisterSignerMaterialResolver(resolver) +} + +func defaultSignerMaterialResolverProviderForBuild() (SignerMaterialResolver, error) { + return &buildTaggedNativeSignerMaterialResolver{}, nil +} + +// buildTaggedNativeSignerMaterialResolver derives transitional signer material +// for frost_tbtc_signer builds. It carries a deterministic key-group handle and +// embeds legacy private-key-share bytes to preserve temporary Go-side fallback. +type buildTaggedNativeSignerMaterialResolver struct{} + +func (btnsmr *buildTaggedNativeSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + if privateKeyShare == nil { + return nil, fmt.Errorf("private key share is nil") + } + + legacyPrivateKeySharePayload, err := privateKeyShare.Marshal() + if err != nil { + return nil, fmt.Errorf("cannot marshal private key share: [%w]", err) + } + + walletPublicKeyBytes, err := marshalPublicKey(privateKeyShare.PublicKey()) + if err != nil { + return nil, fmt.Errorf("cannot marshal wallet public key: [%w]", err) + } + + keyGroupDigest := sha256.Sum256(walletPublicKeyBytes) + + // Scaffold-era key-group derivation: the current value identifies + // placeholder material derived from the legacy wallet public-key hash, + // not the output of a real FROST DKG run. Refuse to surface that material + // at all unless the operator has explicitly opted in via + // AcceptScaffoldKeyGroupEnvVar — production deployments must never set + // this. See native_tbtc_signer_material.go for the env-var contract. + if !frostsigning.AcceptScaffoldKeyGroupEnabled() { + return nil, fmt.Errorf( + "refusing to build scaffold-era %q signer material; set %s=true to "+ + "opt in for local/CI use only, never in production", + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + frostsigning.AcceptScaffoldKeyGroupEnvVar, + ) + } + + // TODO: Replace this placeholder key-group derivation with Rust DKG output. + // The current value identifies scaffold-era material only. + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: hex.EncodeToString(keyGroupDigest[:]), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: hex.EncodeToString(legacyPrivateKeySharePayload), + }) + if err != nil { + return nil, fmt.Errorf("cannot marshal tbtc signer material payload: [%w]", err) + } + + return &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + }, nil +} diff --git a/pkg/tbtc/signer_material_resolver_build_frost_native_test.go b/pkg/tbtc/signer_material_resolver_build_frost_native_test.go new file mode 100644 index 0000000000..23f4b36b8b --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_build_frost_native_test.go @@ -0,0 +1,242 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestRegisterSignerMaterialResolverForBuild_UsesDefaultProvider( + t *testing.T, +) { + // Default scaffold-era resolver builds legacy-wallet-pubkey signer + // material; production refuses it but local/CI tests can opt in. + t.Setenv(frostsigning.AcceptScaffoldKeyGroupEnvVar, "true") + + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + + result, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + nativeSignerMaterial, ok := result.(*frostsigning.NativeSignerMaterial) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &frostsigning.NativeSignerMaterial{}, + result, + ) + } + + expectedPayload, err := privateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling expected private key share: [%v]", err) + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { + t.Fatalf( + "unexpected native signer material format\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + nativeSignerMaterial.Format, + ) + } + + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(nativeSignerMaterial.Payload, &payload); err != nil { + t.Fatalf("failed unmarshalling tbtc signer material payload: [%v]", err) + } + + if payload.KeyGroup == "" { + t.Fatal("expected non-empty tbtc-signer key group") + } + + if payload.KeyGroupSource == "" { + t.Fatal("expected non-empty tbtc-signer key group source") + } + + legacyPrivateKeySharePayload, err := hex.DecodeString(payload.LegacyPrivateKeyShareHex) + if err != nil { + t.Fatalf("failed decoding legacy private key share payload: [%v]", err) + } + + decodedPrivateKeyShare := &tecdsa.PrivateKeyShare{} + if err := decodedPrivateKeyShare.Unmarshal(legacyPrivateKeySharePayload); err != nil { + t.Fatalf("failed unmarshalling decoded private key share: [%v]", err) + } + + actualPayload, err := decodedPrivateKeyShare.Marshal() + if err != nil { + t.Fatalf("failed marshaling decoded private key share: [%v]", err) + } + + if !bytes.Equal(expectedPayload, actualPayload) { + t.Fatalf( + "unexpected resolved signer payload\nexpected: [%x]\nactual: [%x]", + expectedPayload, + actualPayload, + ) + } +} + +func TestRegisterSignerMaterialResolverForBuild_UsesRegisteredProvider( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + expected := []byte{0xaa, 0xbb} + err := RegisterSignerMaterialResolverProviderForBuild( + func() (SignerMaterialResolver, error) { + return &staticSignerMaterialResolver{ + result: expected, + }, nil + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + err = RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + result, err := resolveSignerMaterial(createMockSigner(t).privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resultBytes, ok := result.([]byte) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + []byte{}, + result, + ) + } + + if len(resultBytes) != len(expected) || + resultBytes[0] != expected[0] || + resultBytes[1] != expected[1] { + t.Fatalf( + "unexpected resolved signer material\nexpected: [%x]\nactual: [%x]", + expected, + resultBytes, + ) + } +} + +func TestRegisterSignerMaterialResolverForBuild_ProviderError(t *testing.T) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + expectedErr := errors.New("provider error") + err := RegisterSignerMaterialResolverProviderForBuild( + func() (SignerMaterialResolver, error) { + return nil, expectedErr + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + err = RegisterSignerMaterialResolverForBuild() + if err == nil { + t.Fatal("expected build resolver registration error") + } + + if !errors.Is(err, expectedErr) { + t.Fatalf( + "unexpected build resolver registration error\nexpected: [%v]\nactual: [%v]", + expectedErr, + err, + ) + } +} + +func TestRegisterSignerMaterialResolverForBuild_ProviderReturnsNilResolver( + t *testing.T, +) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverProviderForBuild( + func() (SignerMaterialResolver, error) { + return nil, nil + }, + ) + if err != nil { + t.Fatalf("unexpected provider registration error: [%v]", err) + } + + err = RegisterSignerMaterialResolverForBuild() + if err == nil { + t.Fatal("expected build resolver registration error") + } +} + +func TestRegisterSignerMaterialResolverForBuild_DefaultProviderRefusesScaffoldWithoutOptIn( + t *testing.T, +) { + // Force the env var to "" so a stray external value cannot suppress the + // scaffold refusal during this regression test. + t.Setenv(frostsigning.AcceptScaffoldKeyGroupEnvVar, "") + + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + _, err = resolveSignerMaterial(privateKeyShare) + if err == nil { + t.Fatal( + "expected scaffold-refusal error from default resolver without opt-in", + ) + } + + if !strings.Contains(err.Error(), frostsigning.AcceptScaffoldKeyGroupEnvVar) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + frostsigning.AcceptScaffoldKeyGroupEnvVar, + err, + ) + } + if !strings.Contains(err.Error(), frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + err, + ) + } +} diff --git a/pkg/tbtc/signer_material_resolver_default_build_test.go b/pkg/tbtc/signer_material_resolver_default_build_test.go new file mode 100644 index 0000000000..c25489b72e --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_default_build_test.go @@ -0,0 +1,44 @@ +//go:build !frost_native + +package tbtc + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestRegisterSignerMaterialResolverForBuild_DefaultBuildNoop(t *testing.T) { + UnregisterSignerMaterialResolver() + UnregisterSignerMaterialResolverProviderForBuild() + t.Cleanup(UnregisterSignerMaterialResolver) + t.Cleanup(UnregisterSignerMaterialResolverProviderForBuild) + + err := RegisterSignerMaterialResolverForBuild() + if err != nil { + t.Fatalf("unexpected build resolver registration error: [%v]", err) + } + + privateKeyShare := createMockSigner(t).privateKeyShare + result, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resolvedPrivateKeyShare, ok := result.(*tecdsa.PrivateKeyShare) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &tecdsa.PrivateKeyShare{}, + result, + ) + } + + if resolvedPrivateKeyShare != privateKeyShare { + t.Fatalf( + "unexpected resolved private key share\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + resolvedPrivateKeyShare, + ) + } +} diff --git a/pkg/tbtc/signer_material_resolver_test.go b/pkg/tbtc/signer_material_resolver_test.go new file mode 100644 index 0000000000..52ef802800 --- /dev/null +++ b/pkg/tbtc/signer_material_resolver_test.go @@ -0,0 +1,129 @@ +package tbtc + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +type staticSignerMaterialResolver struct { + result any + err error +} + +func (ssmr *staticSignerMaterialResolver) ResolveSignerMaterial( + privateKeyShare *tecdsa.PrivateKeyShare, +) (any, error) { + return ssmr.result, ssmr.err +} + +func TestRegisterSignerMaterialResolver_Nil(t *testing.T) { + err := RegisterSignerMaterialResolver(nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRegisterSignerMaterialResolverProviderForBuild_Nil(t *testing.T) { + err := RegisterSignerMaterialResolverProviderForBuild(nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestResolveSignerMaterial_DefaultResolver(t *testing.T) { + UnregisterSignerMaterialResolver() + t.Cleanup(UnregisterSignerMaterialResolver) + + privateKeyShare := createMockSigner(t).privateKeyShare + + result, err := resolveSignerMaterial(privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resolvedPrivateKeyShare, ok := result.(*tecdsa.PrivateKeyShare) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + &tecdsa.PrivateKeyShare{}, + result, + ) + } + + if resolvedPrivateKeyShare != privateKeyShare { + t.Fatalf( + "unexpected resolved private key share\nexpected: [%v]\nactual: [%v]", + privateKeyShare, + resolvedPrivateKeyShare, + ) + } +} + +func TestResolveSignerMaterial_RegisteredResolver(t *testing.T) { + UnregisterSignerMaterialResolver() + t.Cleanup(UnregisterSignerMaterialResolver) + + expected := []byte{0xaa, 0xbb} + err := RegisterSignerMaterialResolver( + &staticSignerMaterialResolver{ + result: expected, + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + result, err := resolveSignerMaterial(createMockSigner(t).privateKeyShare) + if err != nil { + t.Fatalf("unexpected resolver error: [%v]", err) + } + + resultBytes, ok := result.([]byte) + if !ok { + t.Fatalf( + "unexpected resolved signer material type\nexpected: [%T]\nactual: [%T]", + []byte{}, + result, + ) + } + + if len(resultBytes) != len(expected) || + resultBytes[0] != expected[0] || + resultBytes[1] != expected[1] { + t.Fatalf( + "unexpected resolved signer material\nexpected: [%x]\nactual: [%x]", + expected, + resultBytes, + ) + } +} + +func TestResolveSignerMaterial_ResolverError(t *testing.T) { + UnregisterSignerMaterialResolver() + t.Cleanup(UnregisterSignerMaterialResolver) + + expectedErr := errors.New("resolver error") + err := RegisterSignerMaterialResolver( + &staticSignerMaterialResolver{ + err: expectedErr, + }, + ) + if err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + _, err = resolveSignerMaterial(createMockSigner(t).privateKeyShare) + if err == nil { + t.Fatal("expected resolver error") + } + + if !errors.Is(err, expectedErr) { + t.Fatalf( + "unexpected resolver error\nexpected: [%v]\nactual: [%v]", + expectedErr, + err, + ) + } +} diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 346b6b0446..c7c3d33677 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -9,12 +9,13 @@ import ( "time" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/announcer" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" "go.uber.org/zap" "golang.org/x/sync/semaphore" ) @@ -102,7 +103,7 @@ func (se *signingExecutor) signBatch( ctx context.Context, messages []*big.Int, startBlock uint64, -) ([]*tecdsa.Signature, error) { +) ([]*frost.Signature, error) { wallet := se.wallet() walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) @@ -139,7 +140,7 @@ func (se *signingExecutor) signBatch( ) signingStartBlock := startBlock // start block for the first signing - signatures := make([]*tecdsa.Signature, len(messages)) + signatures := make([]*frost.Signature, len(messages)) endBlocks := make([]uint64, len(messages)) for i, message := range messages { @@ -184,7 +185,7 @@ func (se *signingExecutor) sign( ctx context.Context, message *big.Int, startBlock uint64, -) (*tecdsa.Signature, *signingActivityReport, uint64, error) { +) (*frost.Signature, *signingActivityReport, uint64, error) { if lockAcquired := se.lock.TryAcquire(1); !lockAcquired { // Record failure metrics for lock acquisition failure if se.metricsRecorder != nil { @@ -223,7 +224,7 @@ func (se *signingExecutor) sign( ) type signingOutcome struct { - signature *tecdsa.Signature + signature *frost.Signature activityReport *signingActivityReport endBlock uint64 } @@ -291,12 +292,38 @@ func (se *signingExecutor) sign( zap.Uint64("attemptTimeoutBlock", attempt.timeoutBlock), ) + includedMembersIndexes := attemptIncludedMembersIndexes( + wallet.groupSize(), + attempt.excludedMembersIndexes, + ) + + coordinatorMemberIndex, err := roast.SelectCoordinator( + includedMembersIndexes, + signingAttemptSeed(message), + attempt.number, + ) + if err != nil { + return nil, 0, fmt.Errorf( + "cannot select signing coordinator for attempt [%v]: [%w]", + attempt.number, + err, + ) + } + + attemptInfo := &signing.Attempt{ + Number: attempt.number, + CoordinatorMemberIndex: coordinatorMemberIndex, + IncludedMembersIndexes: includedMembersIndexes, + ExcludedMembersIndexes: attempt.excludedMembersIndexes, + } + signingAttemptLogger.Infof( "[member:%v] starting signing protocol "+ - "with [%v] group members (excluded: [%v])", + "with [%v] group members (coordinator: [%v], excluded: [%v])", signer.signingGroupMemberIndex, - wallet.groupSize()-len(attempt.excludedMembersIndexes), - attempt.excludedMembersIndexes, + len(includedMembersIndexes), + coordinatorMemberIndex, + attemptInfo.ExcludedMembersIndexes, ) // Set up the attempt timeout signal. @@ -319,20 +346,23 @@ func (se *signingExecutor) sign( attempt.number, ) - result, err := signing.Execute( + result, err := signing.ExecuteRequest( attemptCtx, signingAttemptLogger, - message, - sessionID, - signer.signingGroupMemberIndex, - signer.privateKeyShare, - wallet.groupSize(), - wallet.groupDishonestThreshold( - se.groupParameters.HonestThreshold, - ), - attempt.excludedMembersIndexes, - se.broadcastChannel, - se.membershipValidator, + &signing.Request{ + Message: message, + SessionID: sessionID, + MemberIndex: signer.signingGroupMemberIndex, + SignerMaterial: signer.signingMaterial(), + PrivateKeyShare: signer.privateKeyShare, + GroupSize: wallet.groupSize(), + DishonestThreshold: wallet.groupDishonestThreshold( + se.groupParameters.HonestThreshold, + ), + Channel: se.broadcastChannel, + MembershipValidator: se.membershipValidator, + Attempt: attemptInfo, + }, ) if err != nil { return nil, 0, err @@ -437,6 +467,26 @@ func (se *signingExecutor) wallet() wallet { return se.signers[0].wallet } +func attemptIncludedMembersIndexes( + groupSize int, + excludedMembersIndexes []group.MemberIndex, +) []group.MemberIndex { + excludedMembersIndexesSet := make(map[group.MemberIndex]bool) + for _, excludedMemberIndex := range excludedMembersIndexes { + excludedMembersIndexesSet[excludedMemberIndex] = true + } + + includedMembersIndexes := make([]group.MemberIndex, 0) + for i := 0; i < groupSize; i++ { + memberIndex := group.MemberIndex(i + 1) + if !excludedMembersIndexesSet[memberIndex] { + includedMembersIndexes = append(includedMembersIndexes, memberIndex) + } + } + + return includedMembersIndexes +} + // setMetricsRecorder sets the metrics recorder for the signing executor. func (se *signingExecutor) setMetricsRecorder(recorder interface { IncrementCounter(name string, value float64) diff --git a/pkg/tbtc/signing_done.go b/pkg/tbtc/signing_done.go index 58dfeccc83..f14426d87f 100644 --- a/pkg/tbtc/signing_done.go +++ b/pkg/tbtc/signing_done.go @@ -7,10 +7,10 @@ import ( "sync" "time" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) // signingDoneReceiveBuffer is a buffer for messages received from the broadcast @@ -35,7 +35,7 @@ type signingDoneMessage struct { senderID group.MemberIndex message *big.Int attemptNumber uint64 - signature *tecdsa.Signature + signature *frost.Signature endBlock uint64 } @@ -54,7 +54,7 @@ type signingDoneCheck struct { cancelReceiveCtx context.CancelFunc expectedSignersCount int doneSigners map[group.MemberIndex]*signingDoneMessage - doneSignersMutex sync.Mutex + doneSignersMutex sync.RWMutex } func newSigningDoneCheck( @@ -90,14 +90,16 @@ func (sdc *signingDoneCheck) listen( // causes warnings on the channel level. sdc.receiveCtx, sdc.cancelReceiveCtx = context.WithCancel(ctx) + sdc.doneSignersMutex.Lock() + sdc.expectedSignersCount = len(attemptMembersIndexes) + sdc.doneSigners = make(map[group.MemberIndex]*signingDoneMessage) + sdc.doneSignersMutex.Unlock() + messagesChan := make(chan net.Message, signingDoneReceiveBuffer) sdc.broadcastChannel.Recv(sdc.receiveCtx, func(message net.Message) { messagesChan <- message }) - sdc.expectedSignersCount = len(attemptMembersIndexes) - sdc.doneSigners = make(map[group.MemberIndex]*signingDoneMessage) - go func() { for { select { @@ -117,9 +119,9 @@ func (sdc *signingDoneCheck) listen( continue } - sdc.doneSignersMutex.Lock() - sdc.doneSigners[doneMessage.senderID] = doneMessage - sdc.doneSignersMutex.Unlock() + if !sdc.recordDoneMessage(doneMessage) { + continue + } case <-sdc.receiveCtx.Done(): return @@ -169,11 +171,12 @@ func (sdc *signingDoneCheck) waitUntilAllDone(ctx context.Context) ( return nil, 0, errWaitDoneTimedOut case <-ticker.C: - if sdc.expectedSignersCount == len(sdc.doneSigners) { - var signature *tecdsa.Signature + expectedSignersCount, doneSigners := sdc.snapshotDoneSigners() + if expectedSignersCount == len(doneSigners) { + var signature *frost.Signature var latestEndBlock uint64 - for _, doneMessage := range sdc.doneSigners { + for _, doneMessage := range doneSigners { if signature == nil { signature = doneMessage.signature } else { @@ -206,12 +209,6 @@ func (sdc *signingDoneCheck) isValidDoneMessage( attemptNumber uint64, attemptTimeoutBlock uint64, ) bool { - _, signerDone := sdc.doneSigners[doneMessage.senderID] - if signerDone { - // only one done message allowed - return false - } - if !sdc.membershipValidator.IsValidMembership( doneMessage.senderID, senderPublicKey, @@ -237,3 +234,56 @@ func (sdc *signingDoneCheck) isValidDoneMessage( return true } + +func (sdc *signingDoneCheck) recordDoneMessage( + doneMessage *signingDoneMessage, +) bool { + sdc.doneSignersMutex.Lock() + defer sdc.doneSignersMutex.Unlock() + + if _, signerDone := sdc.doneSigners[doneMessage.senderID]; signerDone { + // Only one done message is allowed for the given signer. + return false + } + + sdc.doneSigners[doneMessage.senderID] = doneMessage.clone() + return true +} + +func (sdc *signingDoneCheck) snapshotDoneSigners() ( + int, + []*signingDoneMessage, +) { + sdc.doneSignersMutex.RLock() + defer sdc.doneSignersMutex.RUnlock() + + result := make([]*signingDoneMessage, 0, len(sdc.doneSigners)) + for _, doneMessage := range sdc.doneSigners { + result = append(result, doneMessage.clone()) + } + + return sdc.expectedSignersCount, result +} + +func (sdm *signingDoneMessage) clone() *signingDoneMessage { + if sdm == nil { + return nil + } + + result := &signingDoneMessage{ + senderID: sdm.senderID, + attemptNumber: sdm.attemptNumber, + endBlock: sdm.endBlock, + } + + if sdm.message != nil { + result.message = new(big.Int).Set(sdm.message) + } + + if sdm.signature != nil { + signatureCopy := *sdm.signature + result.signature = &signatureCopy + } + + return result +} diff --git a/pkg/tbtc/signing_done_test.go b/pkg/tbtc/signing_done_test.go index 792edd6b68..720a6133df 100644 --- a/pkg/tbtc/signing_done_test.go +++ b/pkg/tbtc/signing_done_test.go @@ -14,12 +14,11 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/net/local" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) // TestSigningDoneCheck is a happy path test. @@ -46,11 +45,7 @@ func TestSigningDoneCheck(t *testing.T) { attemptTimeoutBlock := uint64(1000) attemptMemberIndexes := memberIndexes[:groupParameters.HonestThreshold] result := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), } type outcome struct { @@ -166,11 +161,7 @@ func TestSigningDoneCheck_MissingConfirmation(t *testing.T) { attemptTimeoutBlock := uint64(1000) attemptMemberIndexes := memberIndexes[:groupParameters.HonestThreshold] result := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), } doneCheck.listen( @@ -229,18 +220,10 @@ func TestSigningDoneCheck_AnotherSignature(t *testing.T) { attemptTimeoutBlock := uint64(1000) attemptMemberIndexes := memberIndexes[:groupParameters.HonestThreshold] correctResult := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(200), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)), } incorrectResult := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(201), - S: big.NewInt(300), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(201), big.NewInt(300)), } doneCheck.listen( diff --git a/pkg/tbtc/signing_loop.go b/pkg/tbtc/signing_loop.go index 7e787f1975..367274ce41 100644 --- a/pkg/tbtc/signing_loop.go +++ b/pkg/tbtc/signing_loop.go @@ -13,9 +13,8 @@ import ( "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa/retry" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" "golang.org/x/exp/slices" ) @@ -45,6 +44,17 @@ func signingAttemptMaximumBlocks() uint { signingAttemptCoolDownBlocks } +// signingAttemptSeed computes a deterministic seed used for retry and +// coordinator selection for a given signed message. +func signingAttemptSeed(message *big.Int) int64 { + // Compute the 8-byte seed needed for the random retry algorithm. We take + // the first 8 bytes of the hash of the signed message. This allows us to + // not care in this piece of the code about the length of the message and + // how this message is proposed. + messageSha256 := sha256.Sum256(message.Bytes()) + return int64(binary.BigEndian.Uint64(messageSha256[:8])) +} + // signingAnnouncer represents a component responsible for exchanging readiness // announcements for the given signing attempt of the given message. type signingAnnouncer interface { @@ -96,6 +106,12 @@ type signingRetryLoop struct { attemptSeed int64 doneCheck signingDoneCheckStrategy + + // participantSelector dispatches qualified-operator selection. + // Default: legacy retry shuffle. Phase 7 may install a + // ROAST-driven implementation behind the frost_roast_retry + // build tag once AggregateBundle production is wired upstream. + participantSelector signingParticipantSelector } func newSigningRetryLoop( @@ -108,13 +124,6 @@ func newSigningRetryLoop( announcer signingAnnouncer, doneCheck signingDoneCheckStrategy, ) *signingRetryLoop { - // Compute the 8-byte seed needed for the random retry algorithm. We take - // the first 8 bytes of the hash of the signed message. This allows us to - // not care in this piece of the code about the length of the message and - // how this message is proposed. - messageSha256 := sha256.Sum256(message.Bytes()) - attemptSeed := int64(binary.BigEndian.Uint64(messageSha256[:8])) - return &signingRetryLoop{ logger: logger, message: message, @@ -124,8 +133,9 @@ func newSigningRetryLoop( announcer: announcer, attemptCounter: 0, attemptStartBlock: initialStartBlock, - attemptSeed: attemptSeed, + attemptSeed: signingAttemptSeed(message), doneCheck: doneCheck, + participantSelector: defaultSigningParticipantSelector(), } } @@ -488,11 +498,16 @@ func (srl *signingRetryLoop) qualifiedOperatorsSet( ) } - qualifiedOperators, err := retry.EvaluateRetryParticipantsForSigning( + // RFC-21 Phase 6.4: dispatch through participantSelector so a + // future ROAST-driven implementation can be installed behind + // the frost_roast_retry build tag without touching this call + // site. Default and current behaviour: legacy retry shuffle. + qualifiedOperators, err := srl.participantSelector.Select( readySigningGroupOperators, srl.attemptSeed, retryCount, uint(srl.groupParameters.HonestThreshold), + fmt.Sprintf("%v", srl.message), ) if err != nil { return nil, fmt.Errorf( diff --git a/pkg/tbtc/signing_loop_legacy_selector.go b/pkg/tbtc/signing_loop_legacy_selector.go new file mode 100644 index 0000000000..f9bc758717 --- /dev/null +++ b/pkg/tbtc/signing_loop_legacy_selector.go @@ -0,0 +1,42 @@ +package tbtc + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/retry" +) + +// legacySigningParticipantSelector is the pre-RFC-21 implementation: +// it calls the pseudo-random retry shuffle in pkg/frost/retry. +// Kept as the canonical fallback through Phase 6; Phase 7 may +// remove it once the ROAST-driven retry path is fully wired and +// the readiness manifest flips. +// +// The legacy code is *intentionally retained* through Phase 6 to +// preserve the operational rollback path: if a deployment toggles +// the readiness env var off, this implementation is what the +// dispatcher falls back to. +type legacySigningParticipantSelector struct{} + +func (legacySigningParticipantSelector) Select( + members []chain.Address, + seed int64, + retryCount uint, + honestThreshold uint, + _ string, +) ([]chain.Address, error) { + qualifiedOperators, err := retry.EvaluateRetryParticipantsForSigning( + members, + seed, + retryCount, + honestThreshold, + ) + if err != nil { + return nil, fmt.Errorf( + "legacy participant selector: random operator selection failed: %w", + err, + ) + } + return qualifiedOperators, nil +} diff --git a/pkg/tbtc/signing_loop_roast_dispatcher.go b/pkg/tbtc/signing_loop_roast_dispatcher.go new file mode 100644 index 0000000000..d9d4dcb088 --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_dispatcher.go @@ -0,0 +1,43 @@ +package tbtc + +import ( + "github.com/keep-network/keep-core/pkg/chain" +) + +// signingParticipantSelector picks the set of operators qualified for +// a signing attempt. The legacy implementation is the pseudo-random +// retry shuffle in pkg/frost/retry; the RFC-21 Phase-6 migration +// introduces this interface so an alternate ROAST-driven +// implementation can be installed behind the frost_roast_retry build +// tag without touching the call site. +// +// PR 6.4 ships the dispatcher with only the legacy implementation +// installed; Phase 7 wires the ROAST-driven implementation along +// with the supporting AggregateBundle production at the executor- +// adapter layer. Until Phase 7, behaviour is byte-identical to +// pre-RFC-21 retry semantics. +type signingParticipantSelector interface { + // Select returns the set of operators qualified to participate + // in the given signing attempt. members is the set of operators + // whose ready signal was received for this attempt. seed is the + // per-message retry seed; retryCount is 0-based (i.e. 0 for the + // first retry). honestThreshold is the group's signing + // threshold. + Select( + members []chain.Address, + seed int64, + retryCount uint, + honestThreshold uint, + sessionID string, + ) ([]chain.Address, error) +} + +// defaultSigningParticipantSelector returns the build-default +// implementation. Default build: the legacy retry shuffle. Tagged +// build (frost_roast_retry, Phase 7.2): a ROAST-driven selector +// that consults the per-session TransitionMessage registry and +// falls back to the legacy selector when no bundle is available. +// +// Defined in build-tagged sibling files +// (signing_loop_selector_*.go) so the right implementation is +// chosen at compile time without runtime branching. diff --git a/pkg/tbtc/signing_loop_roast_dispatcher_test.go b/pkg/tbtc/signing_loop_roast_dispatcher_test.go new file mode 100644 index 0000000000..3d5aa60f00 --- /dev/null +++ b/pkg/tbtc/signing_loop_roast_dispatcher_test.go @@ -0,0 +1,132 @@ +package tbtc + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// Note: TestDefaultSigningParticipantSelector_IsLegacy below is +// build-tag-conditional (see _default_build_test.go); under +// frost_roast_retry the default is the ROAST selector and a +// dedicated test verifies that. + +// recordingSelector counts how often Select was called and returns +// a fixed result. Tests use it to assert the dispatcher routes +// participant selection through the configured selector rather +// than the legacy path. +type recordingSelector struct { + calls int + result []chain.Address + err error +} + +func (r *recordingSelector) Select( + members []chain.Address, + _ int64, + _ uint, + _ uint, + _ string, +) ([]chain.Address, error) { + r.calls++ + if r.err != nil { + return nil, r.err + } + if r.result != nil { + return r.result, nil + } + return members, nil +} + +func TestLegacySigningParticipantSelector_DelegatesToRetryShuffle(t *testing.T) { + members := []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + } + sel := legacySigningParticipantSelector{} + got, err := sel.Select(members, 42, 0, 3, "session-x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) < 3 { + t.Fatalf("expected at least 3 qualified operators, got %d", len(got)) + } +} + +func TestLegacySigningParticipantSelector_PropagatesErrors(t *testing.T) { + sel := legacySigningParticipantSelector{} + _, err := sel.Select( + []chain.Address{chain.Address("op-1")}, + 0, 0, + 99, // honest threshold higher than member count + "session-x", + ) + if err == nil { + t.Fatal("expected error from retry shuffle") + } +} + +func TestSigningRetryLoopUsesDispatcher(t *testing.T) { + sentinel := []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + } + recorder := &recordingSelector{result: sentinel} + + srl := &signingRetryLoop{ + signingGroupOperators: chain.Addresses{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + }, + groupParameters: &GroupParameters{ + HonestThreshold: 3, + }, + attemptCounter: 1, + attemptSeed: 42, + participantSelector: recorder, + } + + set, err := srl.qualifiedOperatorsSet([]group.MemberIndex{1, 2, 3, 4, 5}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if recorder.calls != 1 { + t.Fatalf("expected dispatcher to be called once; got %d", recorder.calls) + } + if len(set) != len(sentinel) { + t.Fatalf( + "expected %d qualified operators (the sentinel), got %d", + len(sentinel), len(set), + ) + } +} + +func TestSigningRetryLoopPropagatesSelectorError(t *testing.T) { + wantErr := errors.New("synthetic selector failure") + srl := &signingRetryLoop{ + signingGroupOperators: chain.Addresses{ + chain.Address("op-1"), + chain.Address("op-2"), + }, + groupParameters: &GroupParameters{HonestThreshold: 2}, + attemptCounter: 1, + attemptSeed: 0, + participantSelector: &recordingSelector{err: wantErr}, + } + _, err := srl.qualifiedOperatorsSet([]group.MemberIndex{1, 2}) + if err == nil { + t.Fatal("expected selector error to propagate") + } + if !errors.Is(err, wantErr) { + t.Fatalf("expected wrapped sentinel; got %v", err) + } +} diff --git a/pkg/tbtc/signing_loop_selector_default_build.go b/pkg/tbtc/signing_loop_selector_default_build.go new file mode 100644 index 0000000000..3eb237e93f --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_default_build.go @@ -0,0 +1,12 @@ +//go:build !frost_roast_retry + +package tbtc + +// defaultSigningParticipantSelector in the default build always +// returns the legacy retry shuffle. The ROAST-driven selector is +// only compiled into the frost_roast_retry build (see +// signing_loop_selector_frost_roast_retry.go) so the default +// production binary contains no ROAST-retry code paths at all. +func defaultSigningParticipantSelector() signingParticipantSelector { + return legacySigningParticipantSelector{} +} diff --git a/pkg/tbtc/signing_loop_selector_default_build_test.go b/pkg/tbtc/signing_loop_selector_default_build_test.go new file mode 100644 index 0000000000..ffb604197c --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_default_build_test.go @@ -0,0 +1,15 @@ +//go:build !frost_roast_retry + +package tbtc + +import "testing" + +func TestDefaultSigningParticipantSelector_IsLegacyInDefaultBuild(t *testing.T) { + sel := defaultSigningParticipantSelector() + if _, ok := sel.(legacySigningParticipantSelector); !ok { + t.Fatalf( + "defaultSigningParticipantSelector in default build must return legacy implementation; got %T", + sel, + ) + } +} diff --git a/pkg/tbtc/signing_loop_selector_frost_roast_retry.go b/pkg/tbtc/signing_loop_selector_frost_roast_retry.go new file mode 100644 index 0000000000..8afe8ee326 --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_frost_roast_retry.go @@ -0,0 +1,127 @@ +//go:build frost_roast_retry + +package tbtc + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// roastSigningParticipantSelector consumes the per-session +// TransitionMessage registry populated by Phase 7.1's bundle +// production. When a bundle is available for the session, it +// invokes EvaluateRoastRetryForSigning to compute the next +// attempt's IncludedSet from the verified evidence. When no bundle +// is available -- typically the first attempt of a session, or +// when the elected coordinator has not yet produced a transition +// message for the current message -- it falls back to the legacy +// retry shuffle. +// +// The selector is installed as defaultSigningParticipantSelector +// when the binary is built with the frost_roast_retry tag and the +// operator opts in via KEEP_CORE_FROST_ROAST_RETRY_ENABLED. +type roastSigningParticipantSelector struct { + legacy legacySigningParticipantSelector +} + +// defaultSigningParticipantSelector in the frost_roast_retry build +// returns the ROAST-driven selector. Its Select method internally +// dispatches to the bundle-based path when a TransitionMessage is +// available and falls back to the legacy shuffle otherwise, so a +// node that has not yet produced any bundles is observationally +// identical to a legacy-only deployment. +func defaultSigningParticipantSelector() signingParticipantSelector { + return roastSigningParticipantSelector{} +} + +// Select chooses the next attempt's qualified operators. When a +// TransitionMessage is present for sessionID, the selector calls +// EvaluateRoastRetryForSigning with a per-call closure resolver +// that maps group.MemberIndex to chain.Address using the supplied +// members slice. When no bundle is present, the selector falls +// back to the legacy retry shuffle. +func (s roastSigningParticipantSelector) Select( + members []chain.Address, + seed int64, + retryCount uint, + honestThreshold uint, + sessionID string, +) ([]chain.Address, error) { + bundle, ok := signing.TransitionBundleForSession(sessionID) + if !ok || bundle == nil { + return s.legacy.Select( + members, seed, retryCount, honestThreshold, sessionID, + ) + } + deps, registryOK := signing.RegisteredRoastRetryCoordinator() + if !registryOK || deps.Coordinator == nil { + // Should not happen in practice (the bundle was produced + // by a registered coordinator) but defend against the + // race anyway. + return s.legacy.Select( + members, seed, retryCount, honestThreshold, sessionID, + ) + } + + // Look up the AttemptHandle bound to this session. The handle + // identifies the attempt whose bundle we are now consuming; + // NextAttempt is invoked against it to derive the next + // AttemptContext's IncludedSet. + handle, _, handleOK := signing.CurrentAttemptHandleForSession(sessionID) + if !handleOK { + return s.legacy.Select( + members, seed, retryCount, honestThreshold, sessionID, + ) + } + + resolver := membersResolver(members) + addresses, _, err := roast.EvaluateRoastRetryForSigning[chain.Address]( + deps.Coordinator, + handle, + bundle, + honestThreshold, + nil, // DKG public key is recomputed inside Coordinator.NextAttempt; passing nil is acceptable when the bundle's attempt context carries the seed binding. + resolver, + ) + if err != nil { + // Hard-fail per RFC-21 Phase-6 error taxonomy: + // EvaluateRoastRetryForSigning surfaces + // ErrAttemptInfeasible (session structurally failed) or + // resolver errors. Neither is safe to silently fall back + // to legacy, because honest signers would all observe the + // same outcome from the same verified bundle. Surface to + // the caller so the session can be terminated cleanly. + return nil, fmt.Errorf( + "roast signing participant selector: %w", + err, + ) + } + return addresses, nil +} + +// membersResolver is the per-call closure that maps +// group.MemberIndex to chain.Address using the supplied slice. +// Member indices are 1-based (per the FROST group convention) and +// the address at index 0 of `members` corresponds to member index +// 1. +type membersResolver []chain.Address + +func (m membersResolver) For(member group.MemberIndex) (chain.Address, error) { + if member == 0 { + return chain.Address(""), fmt.Errorf( + "member resolver: zero member index", + ) + } + idx := int(member) - 1 + if idx >= len(m) { + return chain.Address(""), fmt.Errorf( + "member resolver: member index %d exceeds members slice length %d", + member, len(m), + ) + } + return m[idx], nil +} diff --git a/pkg/tbtc/signing_loop_selector_frost_roast_retry_test.go b/pkg/tbtc/signing_loop_selector_frost_roast_retry_test.go new file mode 100644 index 0000000000..c60a057ff7 --- /dev/null +++ b/pkg/tbtc/signing_loop_selector_frost_roast_retry_test.go @@ -0,0 +1,219 @@ +//go:build frost_roast_retry + +package tbtc + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestDefaultSigningParticipantSelector_IsROASTInTaggedBuild(t *testing.T) { + sel := defaultSigningParticipantSelector() + if _, ok := sel.(roastSigningParticipantSelector); !ok { + t.Fatalf( + "defaultSigningParticipantSelector in frost_roast_retry build must return ROAST impl; got %T", + sel, + ) + } +} + +func TestROASTSelector_FallsBackToLegacyWhenNoBundle(t *testing.T) { + signing.ResetTransitionBundleRegistryForTest() + t.Cleanup(signing.ResetTransitionBundleRegistryForTest) + + sel := roastSigningParticipantSelector{} + members := []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + } + got, err := sel.Select(members, 42, 0, 3, "session-no-bundle") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) < 3 { + t.Fatalf("expected at least 3 from legacy fallback; got %d", len(got)) + } +} + +func TestROASTSelector_FallsBackToLegacyWhenRegistryEmpty(t *testing.T) { + signing.ResetTransitionBundleRegistryForTest() + signing.ResetRoastRetryRegistrationForTest() + signing.ResetSessionHandleRegistryForTest() + t.Cleanup(signing.ResetTransitionBundleRegistryForTest) + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + t.Cleanup(signing.ResetSessionHandleRegistryForTest) + + // Record a bundle but do NOT register a coordinator. + signing.RecordTransitionBundleForSession( + "session-no-registry", + &roast.TransitionMessage{CoordinatorIDValue: 1}, + ) + + sel := roastSigningParticipantSelector{} + members := []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + } + got, err := sel.Select(members, 42, 0, 3, "session-no-registry") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) < 3 { + t.Fatalf("expected at least 3 from legacy fallback; got %d", len(got)) + } +} + +func TestROASTSelector_FallsBackToLegacyWhenNoHandleBinding(t *testing.T) { + signing.ResetTransitionBundleRegistryForTest() + signing.ResetRoastRetryRegistrationForTest() + signing.ResetSessionHandleRegistryForTest() + t.Cleanup(signing.ResetTransitionBundleRegistryForTest) + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + t.Cleanup(signing.ResetSessionHandleRegistryForTest) + + // Register coordinator + record bundle, but DO NOT bind a + // session handle. The selector must still fall back to legacy + // because it cannot identify which attempt to consume the + // bundle against. + signing.RegisterRoastRetryCoordinator(signing.RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + signing.RecordTransitionBundleForSession( + "session-no-handle", + &roast.TransitionMessage{CoordinatorIDValue: 1}, + ) + + sel := roastSigningParticipantSelector{} + members := []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + } + got, err := sel.Select(members, 42, 0, 3, "session-no-handle") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) < 3 { + t.Fatalf("expected legacy fallback; got %d members", len(got)) + } +} + +func TestMembersResolver_MapsIndexToAddress(t *testing.T) { + members := []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + } + r := membersResolver(members) + for i := 1; i <= 3; i++ { + got, err := r.For(group.MemberIndex(i)) + if err != nil { + t.Fatalf("For(%d): %v", i, err) + } + want := members[i-1] + if got != want { + t.Fatalf("For(%d) = %q, want %q", i, got, want) + } + } +} + +func TestMembersResolver_RejectsZeroIndex(t *testing.T) { + r := membersResolver([]chain.Address{chain.Address("op-1")}) + _, err := r.For(0) + if err == nil { + t.Fatal("expected error for zero member index") + } +} + +func TestMembersResolver_RejectsOutOfRangeIndex(t *testing.T) { + r := membersResolver([]chain.Address{chain.Address("op-1")}) + _, err := r.For(99) + if err == nil { + t.Fatal("expected error for out-of-range index") + } +} + +func TestROASTSelector_UsesBundleWhenAllConditionsMet(t *testing.T) { + signing.ResetTransitionBundleRegistryForTest() + signing.ResetRoastRetryRegistrationForTest() + signing.ResetSessionHandleRegistryForTest() + t.Cleanup(signing.ResetTransitionBundleRegistryForTest) + t.Cleanup(signing.ResetRoastRetryRegistrationForTest) + t.Cleanup(signing.ResetSessionHandleRegistryForTest) + + // Build a real coordinator and run through the bundle-production + // flow end-to-end, then verify the selector consumes the bundle + // and returns the IncludedSet mapped to addresses. + ctx, _ := attempt.NewAttemptContext( + "session-with-bundle", + "key-group", + []byte{0x01, 0x02, 0x03}, + [attempt.MessageDigestLength]byte{0xab}, + 0, + []group.MemberIndex{1, 2, 3, 4, 5}, + nil, + ) + + scratch := roast.NewInMemoryCoordinator() + hScratch, _ := scratch.BeginAttempt(ctx) + elected, _ := scratch.SelectedCoordinator(hScratch) + + coord := roast.NewInMemoryCoordinatorWithSigning( + elected, roast.NoOpSigner(), roast.NoOpSignatureVerifier(), + ) + signing.RegisterRoastRetryCoordinator(signing.RoastRetryDeps{ + Coordinator: coord, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: uint32(elected), + }) + + handle, _ := coord.BeginAttempt(ctx) + signing.SetCurrentAttemptHandleForSession("session-with-bundle", handle, ctx) + + // Seed every member's snapshot so AggregateBundle has content. + for _, m := range ctx.IncludedSet { + snap := roast.NewLocalEvidenceSnapshot(m, ctx.Hash(), attempt.Evidence{}) + snap.OperatorSignature = []byte{0x01} + if err := coord.RecordEvidence(handle, snap); err != nil { + t.Fatalf("record %d: %v", m, err) + } + } + bundle, err := coord.AggregateBundle(handle) + if err != nil { + t.Fatalf("aggregate: %v", err) + } + signing.RecordTransitionBundleForSession("session-with-bundle", bundle) + + sel := roastSigningParticipantSelector{} + members := []chain.Address{ + chain.Address("op-1"), + chain.Address("op-2"), + chain.Address("op-3"), + chain.Address("op-4"), + chain.Address("op-5"), + } + got, err := sel.Select(members, 0, 0, 3, "session-with-bundle") + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got) == 0 { + t.Fatal("selector must return at least one address") + } +} diff --git a/pkg/tbtc/signing_loop_test.go b/pkg/tbtc/signing_loop_test.go index 93397a9ef2..f7bef8bd1e 100644 --- a/pkg/tbtc/signing_loop_test.go +++ b/pkg/tbtc/signing_loop_test.go @@ -11,9 +11,8 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/protocol/group" - "github.com/keep-network/keep-core/pkg/tecdsa" - "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) func TestSigningRetryLoop(t *testing.T) { @@ -46,11 +45,7 @@ func TestSigningRetryLoop(t *testing.T) { } testResult := &signing.Result{ - Signature: &tecdsa.Signature{ - R: big.NewInt(300), - S: big.NewInt(400), - RecoveryID: 2, - }, + Signature: mustFrostSignatureFromBigInts(big.NewInt(300), big.NewInt(400)), } var tests = map[string]struct { diff --git a/pkg/tbtc/signing_native_backend_frost_native_test.go b/pkg/tbtc/signing_native_backend_frost_native_test.go new file mode 100644 index 0000000000..cbd45b120d --- /dev/null +++ b/pkg/tbtc/signing_native_backend_frost_native_test.go @@ -0,0 +1,930 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type countingNativeExecutionFFISigningPrimitive struct { + signCalls atomic.Int64 +} + +type deterministicNativeExecutionFFISigningPrimitiveForTBTC struct { + signCalls atomic.Int64 +} + +type attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC struct { + signCalls atomic.Int64 + mutex sync.Mutex + records []attemptTrackingRecordForTBTC +} + +type attemptTrackingRecordForTBTC struct { + attemptNumber uint + includedMemberIndex []group.MemberIndex +} + +type attemptTrackingNativeTBTCSignerEngineForTBTC struct { + mutex sync.Mutex + startCohortsByAttempt map[uint][][]uint16 +} + +var deterministicNativeFROSTSignatureForTBTC = [frost.SignatureSize]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, +} + +func (cnefsp *countingNativeExecutionFFISigningPrimitive) Sign( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + cnefsp.signCalls.Add(1) + return &frost.Signature{}, nil +} + +func (cnefsp *countingNativeExecutionFFISigningPrimitive) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func (dnefspf *deterministicNativeExecutionFFISigningPrimitiveForTBTC) Sign( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + dnefspf.signCalls.Add(1) + + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + nativeSignerMaterial := request.SignerMaterial + if nativeSignerMaterial == nil { + return nil, fmt.Errorf("native signer material is nil") + } + + if nativeSignerMaterial.Format != frostsigning.NativeSignerMaterialFormatFrostUniFFIV2 { + return nil, fmt.Errorf( + "unexpected signer material format: [%s]", + nativeSignerMaterial.Format, + ) + } + + signature := &frost.Signature{} + if err := signature.Unmarshal(deterministicNativeFROSTSignatureForTBTC[:]); err != nil { + return nil, err + } + + return signature, nil +} + +func (dnefspf *deterministicNativeExecutionFFISigningPrimitiveForTBTC) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func (atnefspf *attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC) Sign( + ctx context.Context, + logger log.StandardLogger, + request *frostsigning.NativeExecutionFFISigningRequest, +) (*frost.Signature, error) { + atnefspf.signCalls.Add(1) + + if request == nil { + return nil, fmt.Errorf("request is nil") + } + + if request.Attempt == nil { + return nil, fmt.Errorf("request attempt is nil") + } + + atnefspf.mutex.Lock() + atnefspf.records = append( + atnefspf.records, + attemptTrackingRecordForTBTC{ + attemptNumber: request.Attempt.Number, + includedMemberIndex: append( + []group.MemberIndex{}, + request.Attempt.IncludedMembersIndexes..., + ), + }, + ) + atnefspf.mutex.Unlock() + + // Force retry-loop progression so the next attempt is exercised. + if request.Attempt.Number == 1 { + return nil, fmt.Errorf("forced attempt failure") + } + + signature := &frost.Signature{} + if err := signature.Unmarshal(deterministicNativeFROSTSignatureForTBTC[:]); err != nil { + return nil, err + } + + return signature, nil +} + +func (atnefspf *attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC) RegisterUnmarshallers( + channel net.BroadcastChannel, +) { +} + +func (atnefspf *attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC) uniqueCohortsByAttempt() map[uint][][]group.MemberIndex { + atnefspf.mutex.Lock() + defer atnefspf.mutex.Unlock() + + result := make(map[uint][][]group.MemberIndex) + seen := make(map[uint]map[string]struct{}) + + for _, record := range atnefspf.records { + if seen[record.attemptNumber] == nil { + seen[record.attemptNumber] = make(map[string]struct{}) + } + + keyParts := make([]string, 0, len(record.includedMemberIndex)) + for _, memberIndex := range record.includedMemberIndex { + keyParts = append( + keyParts, + strconv.FormatUint(uint64(memberIndex), 10), + ) + } + cohortKey := strings.Join(keyParts, ",") + + if _, ok := seen[record.attemptNumber][cohortKey]; ok { + continue + } + + seen[record.attemptNumber][cohortKey] = struct{}{} + result[record.attemptNumber] = append( + result[record.attemptNumber], + append([]group.MemberIndex{}, record.includedMemberIndex...), + ) + } + + return result +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) Version() (string, error) { + return "tbtc-signer/0.1.0-bootstrap", nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) RunDKG( + sessionID string, + participants []frostsigning.NativeTBTCSignerDKGParticipant, + threshold uint16, +) (*frostsigning.NativeTBTCSignerDKGResult, error) { + return &frostsigning.NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: "group-1", + ParticipantCount: uint16(len(participants)), + Threshold: threshold, + CreatedAtUnix: 1, + }, nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) StartSignRound( + sessionID string, + memberIdentifier uint16, + message []byte, + keyGroup string, + signingParticipants []uint16, +) (*frostsigning.NativeTBTCSignerRoundState, error) { + attemptNumber, err := attemptNumberFromSessionIDForTBTC(sessionID) + if err != nil { + return nil, err + } + + if keyGroup == "" { + return nil, fmt.Errorf("key group is empty") + } + + if memberIdentifier == 0 { + return nil, fmt.Errorf("member identifier is zero") + } + + if len(message) == 0 { + return nil, fmt.Errorf("message is empty") + } + + if len(signingParticipants) == 0 { + return nil, fmt.Errorf("signing participants are empty") + } + + atntsfe.mutex.Lock() + if atntsfe.startCohortsByAttempt == nil { + atntsfe.startCohortsByAttempt = make(map[uint][][]uint16) + } + + cohort := append([]uint16{}, signingParticipants...) + atntsfe.startCohortsByAttempt[attemptNumber] = append( + atntsfe.startCohortsByAttempt[attemptNumber], + cohort, + ) + atntsfe.mutex.Unlock() + + return &frostsigning.NativeTBTCSignerRoundState{ + SessionID: sessionID, + RoundID: fmt.Sprintf("round-%v", attemptNumber), + RequiredContributions: uint16(len(signingParticipants)), + MessageDigestHex: "00", + SigningParticipants: append([]uint16{}, signingParticipants...), + OwnContribution: &frostsigning.NativeTBTCSignerRoundContribution{ + Identifier: memberIdentifier, + Data: []byte{byte(memberIdentifier), byte(attemptNumber)}, + }, + }, nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) FinalizeSignRound( + sessionID string, + roundContributions []frostsigning.NativeTBTCSignerRoundContribution, +) ([]byte, error) { + if _, err := attemptNumberFromSessionIDForTBTC(sessionID); err != nil { + return nil, err + } + + if len(roundContributions) == 0 { + return nil, fmt.Errorf("round contributions are empty") + } + + return []byte{0xaa}, nil +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) BuildTaprootTx( + sessionID string, + inputs []frostsigning.NativeTBTCSignerTxInput, + outputs []frostsigning.NativeTBTCSignerTxOutput, + scriptTreeHex *string, +) (*frostsigning.NativeTBTCSignerTxResult, error) { + return nil, fmt.Errorf("not implemented") +} + +func (atntsfe *attemptTrackingNativeTBTCSignerEngineForTBTC) uniqueStartCohortsByAttempt() map[uint][][]uint16 { + atntsfe.mutex.Lock() + defer atntsfe.mutex.Unlock() + + result := make(map[uint][][]uint16) + seen := make(map[uint]map[string]struct{}) + + for attemptNumber, cohorts := range atntsfe.startCohortsByAttempt { + if seen[attemptNumber] == nil { + seen[attemptNumber] = make(map[string]struct{}) + } + + for _, cohort := range cohorts { + parts := make([]string, 0, len(cohort)) + for _, participant := range cohort { + parts = append(parts, strconv.FormatUint(uint64(participant), 10)) + } + key := strings.Join(parts, ",") + + if _, ok := seen[attemptNumber][key]; ok { + continue + } + + seen[attemptNumber][key] = struct{}{} + result[attemptNumber] = append(result[attemptNumber], append([]uint16{}, cohort...)) + } + } + + return result +} + +func attemptNumberFromSessionIDForTBTC(sessionID string) (uint, error) { + separatorIndex := strings.LastIndex(sessionID, "-") + if separatorIndex < 0 || separatorIndex == len(sessionID)-1 { + return 0, fmt.Errorf("invalid session id format: [%s]", sessionID) + } + + attemptNumber, err := strconv.ParseUint(sessionID[separatorIndex+1:], 10, 64) + if err != nil { + return 0, fmt.Errorf("cannot parse attempt number from session id [%s]: [%w]", sessionID, err) + } + + return uint(attemptNumber), nil +} + +func TestConfigureFrostSigningBackend_FFIStrictConfigured_BuildAdapter(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend configuration error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } +} + +func TestConfigureFrostSigningBackend_FFIStrictUnavailable_NoBridge(t *testing.T) { + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + // Remove build-registered bridge and executor to exercise strict ffi + // configuration when no native cryptography path is available. + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err == nil { + t.Fatal("expected strict ffi backend configuration error") + } + + if !errors.Is(err, frostsigning.ErrNativeExecutionBackendUnavailable) { + t.Fatalf( + "unexpected strict ffi backend error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeExecutionBackendUnavailable, + err, + ) + } + + if !errors.Is(err, frostsigning.ErrNativeCryptographyUnavailable) { + t.Fatalf( + "unexpected strict ffi native-availability error\nexpected: [%v]\nactual: [%v]", + frostsigning.ErrNativeCryptographyUnavailable, + err, + ) + } +} + +func TestSigningExecutor_Sign_NativeBackend(t *testing.T) { + executor := setupSigningExecutor(t) + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err := configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected native backend signing error: [%v]", err) + } + + // Transitional path note: + // The current native-tag adapter delegates to legacy tECDSA signing. + // Switch this verification to Schnorr/BIP-340 once native FROST crypto + // execution is linked. + walletPublicKey := executor.wallet().publicKey + if !ecdsa.Verify( + walletPublicKey, + message.Bytes(), + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), + ) { + t.Fatalf("invalid signature: [%+v]", signature) + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_FFIStrictBackend_WithNativeSignerMaterial( + t *testing.T, +) { + executor := setupSigningExecutor(t) + configureSignersWithNativeFROSTUniFFIV2Material(t, executor) + + primitive := &deterministicNativeExecutionFFISigningPrimitiveForTBTC{} + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err := frostsigning.RegisterNativeExecutionFFISigningPrimitive(primitive) + if err != nil { + t.Fatalf("unexpected native FFI primitive registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected strict ffi signing error: [%v]", err) + } + + signatureBytes, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + if !bytes.Equal(signatureBytes, deterministicNativeFROSTSignatureForTBTC[:]) { + t.Fatalf( + "unexpected native FROST signature\nexpected: [%x]\nactual: [%x]", + deterministicNativeFROSTSignatureForTBTC[:], + signatureBytes, + ) + } + + if primitive.signCalls.Load() == 0 { + t.Fatal("expected native FFI primitive sign call") + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_NativeBackend_FallsBackWhenOnlyLegacySignerMaterial( + t *testing.T, +) { + executor := setupSigningExecutor(t) + + // Force legacy-only signer material to exercise fallback classification + // behavior even when frost_native build defaults resolve to native material. + for _, signer := range executor.signers { + signer.signerMaterial = signer.privateKeyShare + } + + primitive := &countingNativeExecutionFFISigningPrimitive{} + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err := frostsigning.RegisterNativeExecutionFFISigningPrimitive(primitive) + if err != nil { + t.Fatalf("unexpected native FFI primitive registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "native"}) + if err != nil { + t.Fatalf("unexpected native backend config error: [%v]", err) + } + + if frostsigning.CurrentExecutionBackendName() != frostsigning.NativeExecutionBackendName { + t.Fatalf( + "unexpected backend name\nexpected: [%s]\nactual: [%s]", + frostsigning.NativeExecutionBackendName, + frostsigning.CurrentExecutionBackendName(), + ) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected native backend signing error: [%v]", err) + } + + if primitive.signCalls.Load() != 0 { + t.Fatalf( + "unexpected native primitive sign calls count\nexpected: [%d]\nactual: [%d]", + 0, + primitive.signCalls.Load(), + ) + } + + walletPublicKey := executor.wallet().publicKey + if !ecdsa.Verify( + walletPublicKey, + message.Bytes(), + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), + ) { + t.Fatalf("invalid signature: [%+v]", signature) + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_FFIStrictBackend_AttemptVariationChangesCohortSelection( + t *testing.T, +) { + executor := setupSigningExecutor(t) + configureSignersWithNativeFROSTUniFFIV2Material(t, executor) + + primitive := &attemptTrackingNativeExecutionFFISigningPrimitiveForTBTC{} + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err := frostsigning.RegisterNativeExecutionFFISigningPrimitive(primitive) + if err != nil { + t.Fatalf("unexpected native FFI primitive registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected strict ffi signing error: [%v]", err) + } + + signatureBytes, err := signature.Marshal() + if err != nil { + t.Fatalf("cannot marshal signature: [%v]", err) + } + + if !bytes.Equal(signatureBytes, deterministicNativeFROSTSignatureForTBTC[:]) { + t.Fatalf( + "unexpected native FROST signature\nexpected: [%x]\nactual: [%x]", + deterministicNativeFROSTSignatureForTBTC[:], + signatureBytes, + ) + } + + if primitive.signCalls.Load() == 0 { + t.Fatal("expected native FFI primitive sign call") + } + + cohortsByAttempt := primitive.uniqueCohortsByAttempt() + attemptOneCohorts, ok := cohortsByAttempt[1] + if !ok { + t.Fatal("expected observed cohort for attempt 1") + } + if len(attemptOneCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 1\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptOneCohorts), + ) + } + + attemptTwoCohorts, ok := cohortsByAttempt[2] + if !ok { + t.Fatal("expected observed cohort for attempt 2") + } + if len(attemptTwoCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 2\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptTwoCohorts), + ) + } + + attemptOneCohort := attemptOneCohorts[0] + attemptTwoCohort := attemptTwoCohorts[0] + + expectedCohortSize := executor.groupParameters.HonestThreshold + if len(attemptOneCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 1\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptOneCohort), + ) + } + if len(attemptTwoCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 2\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptTwoCohort), + ) + } + + if reflect.DeepEqual(attemptOneCohort, attemptTwoCohort) { + t.Fatalf( + "expected cohort variation across attempts\nattempt 1: [%v]\nattempt 2: [%v]", + attemptOneCohort, + attemptTwoCohort, + ) + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func TestSigningExecutor_Sign_FFIStrictBackend_TBTCSignerPath_AttemptVariationChangesCohortSelection( + t *testing.T, +) { + executor := setupSigningExecutor(t) + configureSignersWithTBTCSignerMaterial(t, executor, 3) + + nativeTBTCSignerEngine := &attemptTrackingNativeTBTCSignerEngineForTBTC{} + + frostsigning.UnregisterNativeTBTCSignerEngine() + frostsigning.UnregisterNativeTBTCSignerFallbackObserver() + t.Cleanup(frostsigning.UnregisterNativeTBTCSignerEngine) + t.Cleanup(frostsigning.UnregisterNativeTBTCSignerFallbackObserver) + + var fallbackEvents []frostsigning.NativeTBTCSignerFallbackEvent + err := frostsigning.RegisterNativeTBTCSignerFallbackObserver( + func(event frostsigning.NativeTBTCSignerFallbackEvent) { + fallbackEvents = append(fallbackEvents, event) + }, + ) + if err != nil { + t.Fatalf("unexpected fallback observer registration error: [%v]", err) + } + + frostsigning.ResetExecutionBackend() + frostsigning.UnregisterNativeExecutionAdapter() + frostsigning.UnregisterNativeExecutionBridge() + frostsigning.UnregisterNativeExecutionFFIExecutor() + frostsigning.RegisterNativeExecutionAdapterForBuild() + err = frostsigning.RegisterNativeTBTCSignerEngine(nativeTBTCSignerEngine) + if err != nil { + t.Fatalf("unexpected native tbtc-signer engine registration error: [%v]", err) + } + t.Cleanup(frostsigning.ResetExecutionBackend) + t.Cleanup(frostsigning.UnregisterNativeExecutionAdapter) + t.Cleanup(frostsigning.UnregisterNativeExecutionBridge) + t.Cleanup(frostsigning.UnregisterNativeExecutionFFIExecutor) + + err = configureFrostSigningBackend(Config{FrostSigningBackend: "ffi"}) + if err != nil { + t.Fatalf("unexpected strict ffi backend config error: [%v]", err) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + startBlock := uint64(0) + + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) + if err != nil { + t.Fatalf("unexpected strict ffi tbtc-signer-path signing error: [%v]", err) + } + + walletPublicKey := executor.wallet().publicKey + if !ecdsa.Verify( + walletPublicKey, + message.Bytes(), + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), + ) { + t.Fatalf("invalid signature: [%+v]", signature) + } + + cohortsByAttempt := nativeTBTCSignerEngine.uniqueStartCohortsByAttempt() + attemptOneCohorts, ok := cohortsByAttempt[1] + if !ok { + t.Fatal("expected observed StartSignRound cohort for attempt 1") + } + if len(attemptOneCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 1\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptOneCohorts), + ) + } + + attemptTwoCohorts, ok := cohortsByAttempt[2] + if !ok { + t.Fatal("expected observed StartSignRound cohort for attempt 2") + } + if len(attemptTwoCohorts) != 1 { + t.Fatalf( + "unexpected unique cohort count for attempt 2\nexpected: [%d]\nactual: [%d]", + 1, + len(attemptTwoCohorts), + ) + } + + attemptOneCohort := attemptOneCohorts[0] + attemptTwoCohort := attemptTwoCohorts[0] + + expectedCohortSize := executor.groupParameters.HonestThreshold + if len(attemptOneCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 1\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptOneCohort), + ) + } + if len(attemptTwoCohort) != expectedCohortSize { + t.Fatalf( + "unexpected cohort size for attempt 2\nexpected: [%d]\nactual: [%d]", + expectedCohortSize, + len(attemptTwoCohort), + ) + } + + if !containsParticipantForTBTC(attemptOneCohort, 3) { + t.Fatalf( + "expected attempt 1 cohort to include broken signer member 3\nactual: [%v]", + attemptOneCohort, + ) + } + + if containsParticipantForTBTC(attemptTwoCohort, 3) { + t.Fatalf( + "expected attempt 2 cohort to exclude broken signer member 3\nactual: [%v]", + attemptTwoCohort, + ) + } + + if reflect.DeepEqual(attemptOneCohort, attemptTwoCohort) { + t.Fatalf( + "expected cohort variation across attempts\nattempt 1: [%v]\nattempt 2: [%v]", + attemptOneCohort, + attemptTwoCohort, + ) + } + + missingLegacyFallbackObserved := false + for _, event := range fallbackEvents { + if !event.LegacyPrivateKeyShareExists { + missingLegacyFallbackObserved = true + break + } + } + if !missingLegacyFallbackObserved { + t.Fatal("expected at least one fallback event without legacy private key share") + } + + if endBlock <= startBlock { + t.Fatal("wrong end block") + } +} + +func configureSignersWithNativeFROSTUniFFIV2Material( + t *testing.T, + executor *signingExecutor, +) { + t.Helper() + + publicKeyPackage := &frostsigning.NativeFROSTPublicKeyPackage{ + VerifyingShares: map[string]string{ + "1": "share-1", + }, + VerifyingKey: "group-verifying-key", + } + + for _, signer := range executor.signers { + keyPackage := &frostsigning.NativeFROSTKeyPackage{ + Identifier: strconv.FormatUint(uint64(signer.signingGroupMemberIndex), 10), + Data: []byte{byte(signer.signingGroupMemberIndex)}, + } + + payload, err := json.Marshal(struct { + KeyPackage *frostsigning.NativeFROSTKeyPackage `json:"keyPackage"` + PublicKeyPackage *frostsigning.NativeFROSTPublicKeyPackage `json:"publicKeyPackage"` + }{ + KeyPackage: keyPackage, + PublicKeyPackage: publicKeyPackage, + }) + if err != nil { + t.Fatalf("cannot marshal native signer material payload: [%v]", err) + } + + signer.signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV2, + Payload: payload, + } + } +} + +func configureSignersWithTBTCSignerMaterial( + t *testing.T, + executor *signingExecutor, + brokenMemberIndex group.MemberIndex, +) { + t.Helper() + + for _, signer := range executor.signers { + legacyPrivateKeyShareHex := "" + if signer.signingGroupMemberIndex != brokenMemberIndex { + legacyPrivateKeySharePayload, err := signer.privateKeyShare.Marshal() + if err != nil { + t.Fatalf("cannot marshal private key share: [%v]", err) + } + + legacyPrivateKeyShareHex = hex.EncodeToString(legacyPrivateKeySharePayload) + } + + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: "group-1", + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + LegacyPrivateKeyShareHex: legacyPrivateKeyShareHex, + }) + if err != nil { + t.Fatalf("cannot marshal tbtc-signer material payload: [%v]", err) + } + + signer.signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + } +} + +func containsParticipantForTBTC(cohort []uint16, memberIndex uint16) bool { + for _, participant := range cohort { + if participant == memberIndex { + return true + } + } + + return false +} diff --git a/pkg/tbtc/signing_runtime_helpers_test.go b/pkg/tbtc/signing_runtime_helpers_test.go new file mode 100644 index 0000000000..42418e03d0 --- /dev/null +++ b/pkg/tbtc/signing_runtime_helpers_test.go @@ -0,0 +1,34 @@ +package tbtc + +import ( + "math/big" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestAttemptIncludedMembersIndexes(t *testing.T) { + included := attemptIncludedMembersIndexes( + 6, + []group.MemberIndex{6, 2, 4, 2}, + ) + + expected := []group.MemberIndex{1, 3, 5} + if !reflect.DeepEqual(expected, included) { + t.Fatalf("unexpected included members\nexpected: [%v]\nactual: [%v]", expected, included) + } +} + +func TestSigningAttemptSeed(t *testing.T) { + first := signingAttemptSeed(big.NewInt(100)) + again := signingAttemptSeed(big.NewInt(100)) + if first != again { + t.Fatalf("seed should be stable\nfirst: [%v]\nagain: [%v]", first, again) + } + + second := signingAttemptSeed(big.NewInt(101)) + if first == second { + t.Fatal("different messages should produce different attempt seeds") + } +} diff --git a/pkg/tbtc/signing_test.go b/pkg/tbtc/signing_test.go index 9298ad7d7f..9ac73be7ee 100644 --- a/pkg/tbtc/signing_test.go +++ b/pkg/tbtc/signing_test.go @@ -38,8 +38,8 @@ func TestSigningExecutor_Sign(t *testing.T) { if !ecdsa.Verify( walletPublicKey, message.Bytes(), - signature.R, - signature.S, + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), ) { t.Errorf("invalid signature: [%+v]", signature) } @@ -99,8 +99,8 @@ func TestSigningExecutor_SignBatch(t *testing.T) { if !ecdsa.Verify( walletPublicKey, messages[i].Bytes(), - signature.R, - signature.S, + new(big.Int).SetBytes(signature.R[:]), + new(big.Int).SetBytes(signature.S[:]), ) { t.Errorf("invalid signature [%v]: [%+v]", i, signature) } @@ -110,6 +110,14 @@ func TestSigningExecutor_SignBatch(t *testing.T) { // setupSigningExecutor sets up an instance of the signing executor ready // to perform test signing. func setupSigningExecutor(t *testing.T) *signingExecutor { + // Tests in this suite exercise the keep-tbtc signing executor against + // in-process tECDSA fixtures. Under the `frost_native frost_tbtc_signer` + // build tags, the signer-material resolver refuses scaffold-era + // (legacy-wallet-pubkey) material by default; the fixtures here are + // inherently scaffold-era so the executor needs the operator opt-in to + // continue running. Production deployments must never set this env var. + t.Setenv("KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP", "true") + groupParameters := &GroupParameters{ GroupSize: 5, GroupQuorum: 4, diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 62b226aed6..a2b5620ed4 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -65,6 +65,13 @@ type Config struct { PreParamsGenerationConcurrency int // Concurrency level for key-generation for tECDSA. KeyGenerationConcurrency int + // FrostSigningBackend selects the FROST signing backend implementation. + // Supported values are resolved by pkg/frost/signing.SetExecutionBackendByName. + // Empty value defaults to the transitional legacy bridge backend. + // `native` allows transitional legacy fallback when native cryptographic + // execution is unavailable. `ffi` requires native execution and does not + // allow fallback. + FrostSigningBackend string } // Initialize kicks off the TBTC by initializing internal state, ensuring diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index 1da076356b..561f8dab9c 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -8,6 +8,8 @@ import ( "encoding/hex" "fmt" "math/big" + "os" + "strings" "sync" "time" @@ -17,11 +19,17 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" "go.uber.org/zap" ) +type unsignedTransactionInputReference struct { + TxIDHex string + Vout uint32 +} + // WalletActionType represents actions types that can be performed by a wallet. type WalletActionType uint8 @@ -281,7 +289,7 @@ type walletSigningExecutor interface { ctx context.Context, messages []*big.Int, startBlock uint64, - ) ([]*tecdsa.Signature, error) + ) ([]*frost.Signature, error) } // walletTransactionExecutor is a component allowing to sign and broadcast @@ -295,6 +303,11 @@ type walletTransactionExecutor struct { waitForBlockFn waitForBlockFn } +var buildTaprootTxViaNativeSignerFn = buildTaprootTxViaNativeSigner +var nativeBuildTaprootTxSigningSubstitutionEnabledFn = nativeBuildTaprootTxSigningSubstitutionEnabled + +const nativeBuildTaprootTxSigningSubstitutionEnvVar = "KEEP_CORE_NATIVE_BUILDTX_SIGNING_SUBSTITUTION" + func newWalletTransactionExecutor( btcChain bitcoin.Chain, executingWallet wallet, @@ -318,6 +331,49 @@ func (wte *walletTransactionExecutor) signTransaction( signingStartBlock uint64, signingTimeoutBlock uint64, ) (*bitcoin.Transaction, error) { + substitutionEnabled := nativeBuildTaprootTxSigningSubstitutionEnabledFn() + + nativeUnsignedTxHex, err := buildTaprootTxViaNativeSignerFn(unsignedTx) + if err != nil { + return nil, fmt.Errorf( + "error while building unsigned transaction with native tbtc-signer: [%w]", + err, + ) + } + + if nativeUnsignedTxHex != "" { + signTxLogger.Debugf( + "received unsigned transaction from native tbtc-signer BuildTaprootTx [txHexLen:%d]", + len(nativeUnsignedTxHex), + ) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + signTxLogger, + nativeUnsignedTxHex, + unsignedTx.UnsignedTransaction(), + substitutionEnabled, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot process native BuildTaprootTx unsigned transaction for signing: [%v]", + err, + ) + } + + if nativeUnsignedTx != nil { + if err := unsignedTx.ReplaceUnsignedTransaction(nativeUnsignedTx); err != nil { + return nil, fmt.Errorf( + "cannot substitute Go unsigned transaction with native BuildTaprootTx output: [%v]", + err, + ) + } + + signTxLogger.Infof( + "substituted Go unsigned transaction with native tbtc-signer BuildTaprootTx output", + ) + } + } + signTxLogger.Infof("computing transaction's sig hashes") sigHashes, err := unsignedTx.ComputeSignatureHashes() @@ -354,8 +410,8 @@ func (wte *walletTransactionExecutor) signTransaction( containers := make([]*bitcoin.SignatureContainer, len(signatures)) for i, signature := range signatures { containers[i] = &bitcoin.SignatureContainer{ - R: signature.R, - S: signature.S, + R: new(big.Int).SetBytes(signature.R[:]), + S: new(big.Int).SetBytes(signature.S[:]), PublicKey: wte.executingWallet.publicKey, } } @@ -373,6 +429,305 @@ func (wte *walletTransactionExecutor) signTransaction( return tx, nil } +func nativeBuildTaprootTxSigningSubstitutionEnabled() bool { + switch strings.ToLower( + strings.TrimSpace( + os.Getenv(nativeBuildTaprootTxSigningSubstitutionEnvVar), + ), + ) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func evaluateNativeUnsignedTransactionForSigning( + signTxLogger log.StandardLogger, + nativeUnsignedTxHex string, + expectedTransaction *bitcoin.Transaction, + substitutionEnabled bool, +) (*bitcoin.Transaction, error) { + nativeUnsignedTx, err := decodeNativeUnsignedTransactionHex(nativeUnsignedTxHex) + if err != nil { + if substitutionEnabled { + return nil, err + } + + signTxLogger.Warnf( + "cannot compare native BuildTaprootTx unsigned transaction with Go builder state: [%v]", + err, + ) + return nil, nil + } + + diverges, divergenceReason, err := nativeUnsignedTransactionDivergesFromTransaction( + nativeUnsignedTx, + expectedTransaction, + ) + if err != nil { + if substitutionEnabled { + return nil, err + } + + signTxLogger.Warnf( + "cannot compare native BuildTaprootTx unsigned transaction with Go builder state: [%v]", + err, + ) + return nil, nil + } + + if diverges { + divergenceMessage := "native BuildTaprootTx unsigned transaction diverges from Go builder state" + if divergenceReason != "" { + divergenceMessage = fmt.Sprintf( + "%s: %s", + divergenceMessage, + divergenceReason, + ) + } + + if substitutionEnabled { + return nil, fmt.Errorf("%s", divergenceMessage) + } + + signTxLogger.Warnf(divergenceMessage) + } + + if substitutionEnabled { + return nativeUnsignedTx, nil + } + + return nil, nil +} + +func decodeNativeUnsignedTransactionHex( + nativeUnsignedTxHex string, +) (*bitcoin.Transaction, error) { + nativeUnsignedTxBytes, err := hex.DecodeString(nativeUnsignedTxHex) + if err != nil { + return nil, fmt.Errorf("cannot decode native tx hex: [%w]", err) + } + + nativeUnsignedTx := &bitcoin.Transaction{} + if err := nativeUnsignedTx.Deserialize(nativeUnsignedTxBytes); err != nil { + return nil, fmt.Errorf("cannot deserialize native tx bytes: [%w]", err) + } + + return nativeUnsignedTx, nil +} + +func nativeUnsignedTransactionDivergesFromTransaction( + nativeUnsignedTx *bitcoin.Transaction, + expectedTransaction *bitcoin.Transaction, +) (bool, string, error) { + actualShape, err := extractUnsignedTransactionShapeFromTransaction(nativeUnsignedTx) + if err != nil { + return false, "", err + } + + expectedShape, err := extractUnsignedTransactionShapeFromTransaction(expectedTransaction) + if err != nil { + return false, "", err + } + + if actualShape.Version != expectedShape.Version { + return true, fmt.Sprintf( + "version mismatch: expected [%d], got [%d]", + expectedShape.Version, + actualShape.Version, + ), nil + } + + if actualShape.Locktime != expectedShape.Locktime { + return true, fmt.Sprintf( + "locktime mismatch: expected [%d], got [%d]", + expectedShape.Locktime, + actualShape.Locktime, + ), nil + } + + if reason, diverges := unsignedTransactionInputReferencesDivergenceReason( + actualShape.InputReferences, + expectedShape.InputReferences, + ); diverges { + return true, reason, nil + } + + if reason, diverges := unsignedTransactionInputSequencesDivergenceReason( + actualShape.InputSequences, + expectedShape.InputSequences, + ); diverges { + return true, reason, nil + } + + if reason, diverges := unsignedTransactionOutputsDivergenceReason( + actualShape.Outputs, + expectedShape.Outputs, + ); diverges { + return true, reason, nil + } + + return false, "", nil +} + +func unsignedTransactionInputReferencesDivergenceReason( + actual []unsignedTransactionInputReference, + expected []unsignedTransactionInputReference, +) (string, bool) { + if len(actual) != len(expected) { + return fmt.Sprintf( + "input reference count mismatch: expected [%d], got [%d]", + len(expected), + len(actual), + ), true + } + + for i := range actual { + if actual[i] != expected[i] { + return fmt.Sprintf( + "input reference mismatch at index [%d]: expected [%s:%d], got [%s:%d]", + i, + expected[i].TxIDHex, + expected[i].Vout, + actual[i].TxIDHex, + actual[i].Vout, + ), true + } + } + + return "", false +} + +type unsignedTransactionShape struct { + Version int32 + Locktime uint32 + InputReferences []unsignedTransactionInputReference + InputSequences []uint32 + Outputs []bitcoin.UnsignedTransactionOutput +} + +func extractUnsignedTransactionShapeFromTransaction( + transaction *bitcoin.Transaction, +) (*unsignedTransactionShape, error) { + if transaction == nil { + return nil, fmt.Errorf("transaction is nil") + } + + inputReferences := make( + []unsignedTransactionInputReference, + 0, + len(transaction.Inputs), + ) + inputSequences := make([]uint32, 0, len(transaction.Inputs)) + for i, input := range transaction.Inputs { + if input == nil { + return nil, fmt.Errorf("transaction input [%d] is nil", i) + } + + if input.Outpoint == nil { + return nil, fmt.Errorf("transaction input [%d] outpoint is nil", i) + } + + inputReferences = append( + inputReferences, + unsignedTransactionInputReference{ + TxIDHex: input.Outpoint.TransactionHash.Hex(bitcoin.ReversedByteOrder), + Vout: input.Outpoint.OutputIndex, + }, + ) + inputSequences = append(inputSequences, input.Sequence) + } + + outputs := make([]bitcoin.UnsignedTransactionOutput, 0, len(transaction.Outputs)) + for i, output := range transaction.Outputs { + if output == nil { + return nil, fmt.Errorf("transaction output [%d] is nil", i) + } + + if output.Value < 0 { + return nil, fmt.Errorf("transaction output [%d] value is negative", i) + } + + outputs = append( + outputs, + bitcoin.UnsignedTransactionOutput{ + ScriptPubKeyHex: hex.EncodeToString(output.PublicKeyScript), + ValueSats: uint64(output.Value), + }, + ) + } + + return &unsignedTransactionShape{ + Version: transaction.Version, + Locktime: transaction.Locktime, + InputReferences: inputReferences, + InputSequences: inputSequences, + Outputs: outputs, + }, nil +} + +func unsignedTransactionOutputsDivergenceReason( + actual []bitcoin.UnsignedTransactionOutput, + expected []bitcoin.UnsignedTransactionOutput, +) (string, bool) { + if len(actual) != len(expected) { + return fmt.Sprintf( + "output count mismatch: expected [%d], got [%d]", + len(expected), + len(actual), + ), true + } + + for i := range actual { + if actual[i].ValueSats != expected[i].ValueSats { + return fmt.Sprintf( + "output value mismatch at index [%d]: expected [%d], got [%d]", + i, + expected[i].ValueSats, + actual[i].ValueSats, + ), true + } + + if actual[i].ScriptPubKeyHex != expected[i].ScriptPubKeyHex { + return fmt.Sprintf( + "output script mismatch at index [%d]: expected [%s], got [%s]", + i, + expected[i].ScriptPubKeyHex, + actual[i].ScriptPubKeyHex, + ), true + } + } + + return "", false +} + +func unsignedTransactionInputSequencesDivergenceReason( + actual []uint32, + expected []uint32, +) (string, bool) { + if len(actual) != len(expected) { + return fmt.Sprintf( + "input sequence count mismatch: expected [%d], got [%d]", + len(expected), + len(actual), + ), true + } + + for i := range actual { + if actual[i] != expected[i] { + return fmt.Sprintf( + "input sequence mismatch at index [%d]: expected [%d], got [%d]", + i, + expected[i], + actual[i], + ), true + } + } + + return "", false +} + // broadcastTransaction broadcasts a signed Bitcoin transaction until // the transaction lands in the Bitcoin mempool or the provided timeout // is hit, whichever comes first. @@ -779,6 +1134,10 @@ type signer struct { // privateKeyShare is the tECDSA private key share required to participate // in the signing process. privateKeyShare *tecdsa.PrivateKeyShare + + // signerMaterial carries backend-specific signer material used by the + // FROST signing runtime. Legacy path falls back to privateKeyShare. + signerMaterial any } // newSigner constructs a new instance of the wallet's signer. @@ -787,19 +1146,33 @@ func newSigner( walletSigningGroupOperators []chain.Address, signingGroupMemberIndex group.MemberIndex, privateKeyShare *tecdsa.PrivateKeyShare, + signerMaterial any, ) *signer { wallet := wallet{ publicKey: walletPublicKey, signingGroupOperators: walletSigningGroupOperators, } + if signerMaterial == nil { + signerMaterial = privateKeyShare + } + return &signer{ wallet: wallet, signingGroupMemberIndex: signingGroupMemberIndex, privateKeyShare: privateKeyShare, + signerMaterial: signerMaterial, } } +func (s *signer) signingMaterial() any { + if s.signerMaterial != nil { + return s.signerMaterial + } + + return s.privateKeyShare +} + func (s *signer) String() string { return fmt.Sprintf( "signer with index [%v] of wallet [%s]", diff --git a/pkg/tbtc/wallet_id.go b/pkg/tbtc/wallet_id.go new file mode 100644 index 0000000000..6605b762fe --- /dev/null +++ b/pkg/tbtc/wallet_id.go @@ -0,0 +1,30 @@ +package tbtc + +// DeriveLegacyWalletID derives the canonical bridge wallet ID for legacy +// ECDSA wallets from their 20-byte wallet public key hash. +// +// Legacy wallet ID format is a left-padded bytes20 hash: +// bytes32(uint256(uint160(walletPubKeyHash))). +func DeriveLegacyWalletID(walletPublicKeyHash [20]byte) [32]byte { + var walletID [32]byte + copy(walletID[12:], walletPublicKeyHash[:]) + return walletID +} + +// WalletPublicKeyHashFromLegacyWalletID extracts the compatibility wallet +// public key hash from a canonical legacy wallet ID. +// +// Legacy wallet ID format is a left-padded bytes20 hash: +// bytes32(uint256(uint160(walletPubKeyHash))). +func WalletPublicKeyHashFromLegacyWalletID(walletID [32]byte) ([20]byte, bool) { + for i := 0; i < 12; i++ { + if walletID[i] != 0 { + return [20]byte{}, false + } + } + + var walletPublicKeyHash [20]byte + copy(walletPublicKeyHash[:], walletID[12:]) + + return walletPublicKeyHash, true +} diff --git a/pkg/tbtc/wallet_id_test.go b/pkg/tbtc/wallet_id_test.go new file mode 100644 index 0000000000..eb6ee3688e --- /dev/null +++ b/pkg/tbtc/wallet_id_test.go @@ -0,0 +1,89 @@ +package tbtc + +import ( + "encoding/hex" + "testing" +) + +func TestDeriveLegacyWalletID(t *testing.T) { + walletPublicKeyHashBytes, err := hex.DecodeString( + "e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode wallet public key hash: [%v]", err) + } + + var walletPublicKeyHash [20]byte + copy(walletPublicKeyHash[:], walletPublicKeyHashBytes) + + expectedWalletIDBytes, err := hex.DecodeString( + "000000000000000000000000e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode expected wallet ID: [%v]", err) + } + + var expectedWalletID [32]byte + copy(expectedWalletID[:], expectedWalletIDBytes) + + actualWalletID := DeriveLegacyWalletID(walletPublicKeyHash) + if actualWalletID != expectedWalletID { + t.Fatalf( + "unexpected wallet ID\nexpected: [%x]\nactual: [%x]", + expectedWalletID, + actualWalletID, + ) + } +} + +func TestWalletPublicKeyHashFromLegacyWalletID(t *testing.T) { + walletIDBytes, err := hex.DecodeString( + "000000000000000000000000e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode wallet ID: [%v]", err) + } + + var walletID [32]byte + copy(walletID[:], walletIDBytes) + + expectedWalletPublicKeyHashBytes, err := hex.DecodeString( + "e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode expected wallet public key hash: [%v]", err) + } + + var expectedWalletPublicKeyHash [20]byte + copy(expectedWalletPublicKeyHash[:], expectedWalletPublicKeyHashBytes) + + actualWalletPublicKeyHash, ok := WalletPublicKeyHashFromLegacyWalletID(walletID) + if !ok { + t.Fatal("expected wallet ID to be recognized as legacy") + } + + if actualWalletPublicKeyHash != expectedWalletPublicKeyHash { + t.Fatalf( + "unexpected wallet public key hash\nexpected: [%x]\nactual: [%x]", + expectedWalletPublicKeyHash, + actualWalletPublicKeyHash, + ) + } +} + +func TestWalletPublicKeyHashFromLegacyWalletID_NonLegacy(t *testing.T) { + walletIDBytes, err := hex.DecodeString( + "010000000000000000000000e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + ) + if err != nil { + t.Fatalf("failed to decode wallet ID: [%v]", err) + } + + var walletID [32]byte + copy(walletID[:], walletIDBytes) + + _, ok := WalletPublicKeyHashFromLegacyWalletID(walletID) + if ok { + t.Fatal("expected wallet ID to be recognized as non-legacy") + } +} diff --git a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go new file mode 100644 index 0000000000..c935c9c42b --- /dev/null +++ b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go @@ -0,0 +1,1086 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/frost" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestWalletTransactionExecutor_SignTransaction_ReturnsBuildTaprootTxError( + t *testing.T, +) { + privateKey, unsignedTx, _, _ := buildTaprootTxSubstitutionFixture(t) + + original := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = original + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", errors.New("build tx failed") + } + + wte := &walletTransactionExecutor{ + executingWallet: wallet{ + publicKey: &privateKey.PublicKey, + }, + signingExecutor: &unexpectedSigningExecutorForBuildTaprootTxError{}, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + logger := &warningCaptureLogger{} + + _, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected signTransaction error") + } + + if !strings.Contains(err.Error(), "native tbtc-signer") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestWalletTransactionExecutor_SignTransaction_PropagatesBuildTaprootTxBridgeOperationError( + t *testing.T, +) { + privateKey, unsignedTx, _, _ := buildTaprootTxSubstitutionFixture(t) + + original := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = original + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", fmt.Errorf( + "%w: operation failed", + frostsigning.ErrNativeBridgeOperationFailed, + ) + } + + wte := &walletTransactionExecutor{ + executingWallet: wallet{ + publicKey: &privateKey.PublicKey, + }, + signingExecutor: &unexpectedSigningExecutorForBuildTaprootTxError{}, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + logger := &warningCaptureLogger{} + + _, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected signTransaction error") + } + + if !errors.Is(err, frostsigning.ErrNativeBridgeOperationFailed) { + t.Fatalf( + "expected bridge operation failure error: [%v], got [%v]", + frostsigning.ErrNativeBridgeOperationFailed, + err, + ) + } + + if !strings.Contains(err.Error(), "native tbtc-signer") { + t.Fatalf("unexpected error: [%v]", err) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_ObservationalModeLogsWarning( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 999, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + }, + false, + ) + if err != nil { + t.Fatalf("unexpected evaluation error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction substitution in observational mode") + } + + if len(logger.warningMessages) != 1 { + t.Fatalf( + "unexpected warning message count\nexpected: [%v]\nactual: [%v]", + 1, + len(logger.warningMessages), + ) + } + + if !strings.Contains(logger.warningMessages[0], "diverges") { + t.Fatalf("unexpected warning message: [%v]", logger.warningMessages[0]) + } + + if !strings.Contains(logger.warningMessages[0], "output value mismatch") { + t.Fatalf("missing divergence detail in warning: [%v]", logger.warningMessages[0]) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_ObservationalModeLogsWarningOnStructuralDivergence( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + }, + false, + ) + if err != nil { + t.Fatalf("unexpected evaluation error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction substitution in observational mode") + } + + if len(logger.warningMessages) != 1 { + t.Fatalf( + "unexpected warning message count\nexpected: [%v]\nactual: [%v]", + 1, + len(logger.warningMessages), + ) + } + + if !strings.Contains(logger.warningMessages[0], "diverges") { + t.Fatalf("unexpected warning message: [%v]", logger.warningMessages[0]) + } + + if !strings.Contains(logger.warningMessages[0], "version mismatch") { + t.Fatalf("missing divergence detail in warning: [%v]", logger.warningMessages[0]) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_SubstitutionModeRejectsDivergence( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 999, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + }, + true, + ) + if err == nil { + t.Fatal("expected substitution-mode divergence error") + } + + if !strings.Contains(err.Error(), "diverges") { + t.Fatalf("unexpected substitution-mode error: [%v]", err) + } + + if !strings.Contains(err.Error(), "output value mismatch") { + t.Fatalf("missing divergence detail in substitution error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction on divergence") + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warnings in substitution mode: [%v]", logger.warningMessages) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_SubstitutionModeAcceptsMatchingIO( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + nativeTransaction, + true, + ) + if err != nil { + t.Fatalf("unexpected substitution-mode evaluation error: [%v]", err) + } + + if nativeUnsignedTx == nil { + t.Fatal("expected native transaction substitution candidate") + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warnings in substitution mode: [%v]", logger.warningMessages) + } +} + +func TestEvaluateNativeUnsignedTransactionForSigning_SubstitutionModeRejectsStructuralDivergence( + t *testing.T, +) { + logger := &warningCaptureLogger{} + + txHashBytes := make([]byte, bitcoin.HashByteLength) + for i := range txHashBytes { + txHashBytes[i] = byte(i + 1) + } + + txHash, err := bitcoin.NewHash(txHashBytes, bitcoin.InternalByteOrder) + if err != nil { + t.Fatalf("cannot build tx hash: [%v]", err) + } + + scriptPubKey := mustDecodeHex(t, "0014deadbeef") + nativeTransaction := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeTxHex := hex.EncodeToString(nativeTransaction.Serialize(bitcoin.Standard)) + + expectedTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: txHash, + OutputIndex: 7, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 1000, + PublicKeyScript: scriptPubKey, + }, + }, + Locktime: 0, + } + + nativeUnsignedTx, err := evaluateNativeUnsignedTransactionForSigning( + logger, + nativeTxHex, + expectedTransaction, + true, + ) + if err == nil { + t.Fatal("expected substitution-mode structural divergence error") + } + + if !strings.Contains(err.Error(), "diverges") { + t.Fatalf("unexpected substitution-mode error: [%v]", err) + } + + if !strings.Contains(err.Error(), "version mismatch") { + t.Fatalf("missing divergence detail in substitution error: [%v]", err) + } + + if nativeUnsignedTx != nil { + t.Fatal("did not expect native transaction on divergence") + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warnings in substitution mode: [%v]", logger.warningMessages) + } +} + +func TestNativeBuildTaprootTxSigningSubstitutionEnabled(t *testing.T) { + testCases := []struct { + name string + envValue string + expected bool + }{ + {name: "unset", envValue: "", expected: false}, + {name: "true", envValue: "true", expected: true}, + {name: "TRUE", envValue: "TRUE", expected: true}, + {name: "one", envValue: "1", expected: true}, + {name: "yes", envValue: "yes", expected: true}, + {name: "on", envValue: "on", expected: true}, + {name: "false", envValue: "false", expected: false}, + {name: "zero", envValue: "0", expected: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(nativeBuildTaprootTxSigningSubstitutionEnvVar, tc.envValue) + + actual := nativeBuildTaprootTxSigningSubstitutionEnabled() + if actual != tc.expected { + t.Fatalf( + "unexpected flag state\nexpected: [%v]\nactual: [%v]", + tc.expected, + actual, + ) + } + }) + } +} + +func TestWalletTransactionExecutor_SignTransaction_SubstitutesNativeUnsignedTransactionWhenGateEnabled( + t *testing.T, +) { + privateKey, unsignedTx, nativeUnsignedTxHex, nativeUnsignedTx := buildTaprootTxSubstitutionFixture(t) + + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + originalSigningSubstitutionEnabledFn := nativeBuildTaprootTxSigningSubstitutionEnabledFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + nativeBuildTaprootTxSigningSubstitutionEnabledFn = originalSigningSubstitutionEnabledFn + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return nativeUnsignedTxHex, nil + } + nativeBuildTaprootTxSigningSubstitutionEnabledFn = func() bool { + return true + } + + wte := &walletTransactionExecutor{ + executingWallet: wallet{ + publicKey: &privateKey.PublicKey, + }, + signingExecutor: &deterministicECDSASigningExecutorForBuildTaprootTxSubstitution{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + logger := &warningCaptureLogger{} + + tx, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + + if tx.Version != nativeUnsignedTx.Version { + t.Fatalf( + "unexpected substituted transaction version\nexpected: [%v]\nactual: [%v]", + nativeUnsignedTx.Version, + tx.Version, + ) + } + + if tx.Locktime != nativeUnsignedTx.Locktime { + t.Fatalf( + "unexpected substituted transaction locktime\nexpected: [%v]\nactual: [%v]", + nativeUnsignedTx.Locktime, + tx.Locktime, + ) + } + + if len(tx.Inputs) != len(nativeUnsignedTx.Inputs) { + t.Fatalf( + "unexpected substituted input count\nexpected: [%v]\nactual: [%v]", + len(nativeUnsignedTx.Inputs), + len(tx.Inputs), + ) + } + + if tx.Inputs[0].Outpoint.TransactionHash != nativeUnsignedTx.Inputs[0].Outpoint.TransactionHash { + t.Fatalf( + "unexpected substituted input txid\nexpected: [%v]\nactual: [%v]", + nativeUnsignedTx.Inputs[0].Outpoint.TransactionHash, + tx.Inputs[0].Outpoint.TransactionHash, + ) + } + + if tx.Inputs[0].Outpoint.OutputIndex != nativeUnsignedTx.Inputs[0].Outpoint.OutputIndex { + t.Fatalf( + "unexpected substituted input vout\nexpected: [%v]\nactual: [%v]", + nativeUnsignedTx.Inputs[0].Outpoint.OutputIndex, + tx.Inputs[0].Outpoint.OutputIndex, + ) + } + + if tx.Inputs[0].Sequence != nativeUnsignedTx.Inputs[0].Sequence { + t.Fatalf( + "unexpected substituted input sequence\nexpected: [%v]\nactual: [%v]", + nativeUnsignedTx.Inputs[0].Sequence, + tx.Inputs[0].Sequence, + ) + } + + if len(tx.Inputs[0].SignatureScript) == 0 { + t.Fatal("expected signature script to be populated after signing") + } + + if len(tx.Outputs) != len(nativeUnsignedTx.Outputs) { + t.Fatalf( + "unexpected substituted output count\nexpected: [%v]\nactual: [%v]", + len(nativeUnsignedTx.Outputs), + len(tx.Outputs), + ) + } + + if tx.Outputs[0].Value != nativeUnsignedTx.Outputs[0].Value { + t.Fatalf( + "unexpected substituted output value\nexpected: [%v]\nactual: [%v]", + nativeUnsignedTx.Outputs[0].Value, + tx.Outputs[0].Value, + ) + } + + if !bytes.Equal( + tx.Outputs[0].PublicKeyScript, + nativeUnsignedTx.Outputs[0].PublicKeyScript, + ) { + t.Fatalf( + "unexpected substituted output script\nexpected: [%x]\nactual: [%x]", + nativeUnsignedTx.Outputs[0].PublicKeyScript, + tx.Outputs[0].PublicKeyScript, + ) + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warning logs: [%v]", logger.warningMessages) + } + + if !containsLoggedMessage( + logger.infoMessages, + "substituted Go unsigned transaction with native tbtc-signer BuildTaprootTx output", + ) { + t.Fatalf("expected substitution info log, got: [%v]", logger.infoMessages) + } +} + +func TestWalletTransactionExecutor_SignTransaction_DoesNotSubstituteWhenGateDisabled( + t *testing.T, +) { + privateKey, unsignedTx, nativeUnsignedTxHex, _ := buildTaprootTxSubstitutionFixture(t) + + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + originalSigningSubstitutionEnabledFn := nativeBuildTaprootTxSigningSubstitutionEnabledFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + nativeBuildTaprootTxSigningSubstitutionEnabledFn = originalSigningSubstitutionEnabledFn + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return nativeUnsignedTxHex, nil + } + nativeBuildTaprootTxSigningSubstitutionEnabledFn = func() bool { + return false + } + + wte := &walletTransactionExecutor{ + executingWallet: wallet{ + publicKey: &privateKey.PublicKey, + }, + signingExecutor: &deterministicECDSASigningExecutorForBuildTaprootTxSubstitution{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + logger := &warningCaptureLogger{} + + tx, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + + if tx.Version != 1 { + t.Fatalf( + "unexpected non-substituted transaction version\nexpected: [1]\nactual: [%v]", + tx.Version, + ) + } + + if tx.Locktime != 0 { + t.Fatalf( + "unexpected non-substituted transaction locktime\nexpected: [0]\nactual: [%v]", + tx.Locktime, + ) + } + + if tx.Inputs[0].Sequence != 0xffffffff { + t.Fatalf( + "unexpected non-substituted input sequence\nexpected: [4294967295]\nactual: [%v]", + tx.Inputs[0].Sequence, + ) + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warning logs: [%v]", logger.warningMessages) + } + + if containsLoggedMessage( + logger.infoMessages, + "substituted Go unsigned transaction with native tbtc-signer BuildTaprootTx output", + ) { + t.Fatalf("did not expect substitution info log when gate disabled: [%v]", logger.infoMessages) + } +} + +func TestWalletTransactionExecutor_SignTransaction_RejectsNativeUnsignedTransactionDivergenceWhenGateEnabled( + t *testing.T, +) { + privateKey, unsignedTx, _, nativeUnsignedTx := buildTaprootTxSubstitutionFixture(t) + + divergingNativeUnsignedTx := *nativeUnsignedTx + divergingOutputs := make( + []*bitcoin.TransactionOutput, + len(nativeUnsignedTx.Outputs), + ) + for i, output := range nativeUnsignedTx.Outputs { + if output == nil { + t.Fatalf("native fixture output [%d] is nil", i) + } + + clonedOutput := *output + divergingOutputs[i] = &clonedOutput + } + divergingNativeUnsignedTx.Outputs = divergingOutputs + divergingNativeUnsignedTx.Outputs[0].Value = nativeUnsignedTx.Outputs[0].Value - 1 + divergingNativeUnsignedTxHex := hex.EncodeToString( + divergingNativeUnsignedTx.Serialize(bitcoin.Standard), + ) + + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + originalSigningSubstitutionEnabledFn := nativeBuildTaprootTxSigningSubstitutionEnabledFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + nativeBuildTaprootTxSigningSubstitutionEnabledFn = originalSigningSubstitutionEnabledFn + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return divergingNativeUnsignedTxHex, nil + } + nativeBuildTaprootTxSigningSubstitutionEnabledFn = func() bool { + return true + } + + wte := &walletTransactionExecutor{ + executingWallet: wallet{ + publicKey: &privateKey.PublicKey, + }, + signingExecutor: &deterministicECDSASigningExecutorForBuildTaprootTxSubstitution{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + logger := &warningCaptureLogger{} + + tx, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected signTransaction divergence error") + } + + if tx != nil { + t.Fatal("expected no signed transaction on substitution divergence") + } + + if !strings.Contains(err.Error(), "diverges") { + t.Fatalf("unexpected signTransaction divergence error: [%v]", err) + } + + if !strings.Contains(err.Error(), "output value mismatch") { + t.Fatalf("missing divergence detail in signTransaction error: [%v]", err) + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warning logs in substitution mode: [%v]", logger.warningMessages) + } +} + +func TestWalletTransactionExecutor_SignTransaction_RejectsNativeUnsignedTransactionStructuralDivergenceWhenGateEnabled( + t *testing.T, +) { + privateKey, unsignedTx, _, nativeUnsignedTx := buildTaprootTxSubstitutionFixture(t) + + divergingNativeUnsignedTx := *nativeUnsignedTx + divergingInputs := make( + []*bitcoin.TransactionInput, + len(nativeUnsignedTx.Inputs), + ) + for i, input := range nativeUnsignedTx.Inputs { + if input == nil { + t.Fatalf("native fixture input [%d] is nil", i) + } + + clonedInput := *input + divergingInputs[i] = &clonedInput + } + divergingNativeUnsignedTx.Inputs = divergingInputs + divergingNativeUnsignedTx.Version = nativeUnsignedTx.Version + 1 + divergingNativeUnsignedTx.Locktime = nativeUnsignedTx.Locktime + 1 + divergingNativeUnsignedTx.Inputs[0].Sequence = nativeUnsignedTx.Inputs[0].Sequence - 1 + divergingNativeUnsignedTxHex := hex.EncodeToString( + divergingNativeUnsignedTx.Serialize(bitcoin.Standard), + ) + + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + originalSigningSubstitutionEnabledFn := nativeBuildTaprootTxSigningSubstitutionEnabledFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + nativeBuildTaprootTxSigningSubstitutionEnabledFn = originalSigningSubstitutionEnabledFn + }) + + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return divergingNativeUnsignedTxHex, nil + } + nativeBuildTaprootTxSigningSubstitutionEnabledFn = func() bool { + return true + } + + wte := &walletTransactionExecutor{ + executingWallet: wallet{ + publicKey: &privateKey.PublicKey, + }, + signingExecutor: &deterministicECDSASigningExecutorForBuildTaprootTxSubstitution{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + logger := &warningCaptureLogger{} + + tx, err := wte.signTransaction(logger, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected signTransaction structural divergence error") + } + + if tx != nil { + t.Fatal("expected no signed transaction on substitution structural divergence") + } + + if !strings.Contains(err.Error(), "diverges") { + t.Fatalf("unexpected signTransaction divergence error: [%v]", err) + } + + if !strings.Contains(err.Error(), "version mismatch") { + t.Fatalf("missing divergence detail in signTransaction error: [%v]", err) + } + + if len(logger.warningMessages) != 0 { + t.Fatalf("unexpected warning logs in substitution mode: [%v]", logger.warningMessages) + } +} + +func buildTaprootTxSubstitutionFixture( + t *testing.T, +) ( + *ecdsa.PrivateKey, + *bitcoin.TransactionBuilder, + string, + *bitcoin.Transaction, +) { + privateKey := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: tecdsa.Curve, + }, + D: big.NewInt(111), + } + privateKey.PublicKey.X, privateKey.PublicKey.Y = tecdsa.Curve.ScalarBaseMult( + privateKey.D.Bytes(), + ) + + pubKeyHash := [20]byte{} + for i := range pubKeyHash { + pubKeyHash[i] = byte(i + 1) + } + + lockingScript, err := bitcoin.PayToPublicKeyHash(pubKeyHash) + if err != nil { + t.Fatalf("cannot create locking script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{}, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 10000, + PublicKeyScript: lockingScript, + }, + }, + Locktime: 0, + } + + if err := localBitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatalf("cannot broadcast funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddPublicKeyHashInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 10000, + }, + ); err != nil { + t.Fatalf("cannot add unsigned input: [%v]", err) + } + + replacementOutputScript := mustDecodeHex(t, "0014deadbeef") + unsignedTx.AddOutput( + &bitcoin.TransactionOutput{ + Value: 9000, + PublicKeyScript: replacementOutputScript, + }, + ) + + nativeUnsignedTx := &bitcoin.Transaction{ + Version: 1, + Locktime: 0, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 9000, + PublicKeyScript: replacementOutputScript, + }, + }, + } + + return privateKey, + unsignedTx, + hex.EncodeToString(nativeUnsignedTx.Serialize(bitcoin.Standard)), + nativeUnsignedTx +} + +func mustDecodeHex(t *testing.T, value string) []byte { + result, err := hex.DecodeString(value) + if err != nil { + t.Fatalf("cannot decode hex: [%v]", err) + } + + return result +} + +type warningCaptureLogger struct { + warningMessages []string + infoMessages []string +} + +func (wcl *warningCaptureLogger) Debug(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Debugf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Error(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Errorf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Fatal(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Fatalf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Info(args ...interface{}) { + wcl.infoMessages = append(wcl.infoMessages, fmt.Sprint(args...)) +} + +func (wcl *warningCaptureLogger) Infof(format string, args ...interface{}) { + wcl.infoMessages = append(wcl.infoMessages, fmt.Sprintf(format, args...)) +} + +func (wcl *warningCaptureLogger) Panic(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Panicf(format string, args ...interface{}) {} + +func (wcl *warningCaptureLogger) Warn(args ...interface{}) {} + +func (wcl *warningCaptureLogger) Warnf(format string, args ...interface{}) { + wcl.warningMessages = append( + wcl.warningMessages, + fmt.Sprintf(format, args...), + ) +} + +func containsLoggedMessage(messages []string, substring string) bool { + for _, message := range messages { + if strings.Contains(message, substring) { + return true + } + } + + return false +} + +type deterministicECDSASigningExecutorForBuildTaprootTxSubstitution struct { + privateKey *ecdsa.PrivateKey +} + +func (desefbts *deterministicECDSASigningExecutorForBuildTaprootTxSubstitution) signBatch( + ctx context.Context, + messages []*big.Int, + startBlock uint64, +) ([]*frost.Signature, error) { + signatures := make([]*frost.Signature, 0, len(messages)) + + for _, message := range messages { + r, s, err := ecdsa.Sign( + rand.Reader, + desefbts.privateKey, + message.Bytes(), + ) + if err != nil { + return nil, err + } + + signature := &frost.Signature{} + rBytes := r.Bytes() + copy(signature.R[len(signature.R)-len(rBytes):], rBytes) + sBytes := s.Bytes() + copy(signature.S[len(signature.S)-len(sBytes):], sBytes) + + signatures = append(signatures, signature) + } + + return signatures, nil +} + +type unexpectedSigningExecutorForBuildTaprootTxError struct{} + +func (usefbte *unexpectedSigningExecutorForBuildTaprootTxError) signBatch( + ctx context.Context, + messages []*big.Int, + startBlock uint64, +) ([]*frost.Signature, error) { + return nil, errors.New("unexpected signBatch invocation") +} diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 802e3aed3f..f4510f414c 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -8,8 +8,6 @@ import ( "encoding/binary" "encoding/hex" "fmt" - "github.com/keep-network/keep-core/pkg/chain" - "github.com/keep-network/keep-core/pkg/protocol/group" "math/big" "reflect" "sync" @@ -18,6 +16,9 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -418,12 +419,12 @@ func generateWallet(privateKey *big.Int) wallet { type mockWalletSigningExecutor struct { signaturesMutex sync.Mutex - signatures map[[32]byte][]*tecdsa.Signature + signatures map[[32]byte][]*frost.Signature } func newMockWalletSigningExecutor() *mockWalletSigningExecutor { return &mockWalletSigningExecutor{ - signatures: make(map[[32]byte][]*tecdsa.Signature), + signatures: make(map[[32]byte][]*frost.Signature), } } @@ -431,7 +432,7 @@ func (mwse *mockWalletSigningExecutor) signBatch( ctx context.Context, messages []*big.Int, startBlock uint64, -) ([]*tecdsa.Signature, error) { +) ([]*frost.Signature, error) { mwse.signaturesMutex.Lock() defer mwse.signaturesMutex.Unlock() @@ -448,7 +449,7 @@ func (mwse *mockWalletSigningExecutor) signBatch( func (mwse *mockWalletSigningExecutor) setSignatures( messages []*big.Int, startBlock uint64, - signatures []*tecdsa.Signature, + signatures []*frost.Signature, ) { mwse.signaturesMutex.Lock() defer mwse.signaturesMutex.Unlock() diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 52f6ef4137..af56f9ffcf 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -1047,6 +1047,25 @@ func (lc *LocalChain) GetWallet(walletPublicKeyHash [20]byte) ( return data, nil } +func (lc *LocalChain) WalletPublicKeyHashForWalletID( + walletID [32]byte, +) ([20]byte, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + for walletPublicKeyHash, walletData := range lc.walletChainData { + if walletData == nil { + continue + } + + if walletData.WalletID == walletID || walletData.EcdsaWalletID == walletID { + return walletPublicKeyHash, nil + } + } + + return [20]byte{}, fmt.Errorf("wallet public key hash for wallet ID not found") +} + func (lc *LocalChain) SetWallet( walletPublicKeyHash [20]byte, data *tbtc.WalletChainData, diff --git a/pkg/tecdsa/retry/retry.go b/pkg/tecdsa/retry/retry.go index 246e8b1fae..798d3bed30 100644 --- a/pkg/tecdsa/retry/retry.go +++ b/pkg/tecdsa/retry/retry.go @@ -305,7 +305,7 @@ func excludeOperatorTriplets( for k := j + 1; k < len(operators); k++ { leftOperator := operators[i] middleOperator := operators[j] - rightOperator := operators[j] + rightOperator := operators[k] // Only include the operators triples that have few enough seats such // that if they were excluded we still have at least diff --git a/pkg/tecdsa/retry/retry_test.go b/pkg/tecdsa/retry/retry_test.go index 24775c4c7e..5e0a16dbcd 100644 --- a/pkg/tecdsa/retry/retry_test.go +++ b/pkg/tecdsa/retry/retry_test.go @@ -2,6 +2,7 @@ package retry import ( "fmt" + "math/rand" "reflect" "strings" "testing" @@ -118,6 +119,44 @@ func TestEvaluateRetryParticipantsForKeyGeneration_NotEnoughOperators(t *testing } } +func TestExcludeOperatorTriplets_UsesThirdOperatorSeatCount(t *testing.T) { + groupMembers := []chain.Address{ + "A", "A", "A", + "B", + "C", "C", "C", + } + + operatorToSeatCount := calculateSeatCount(groupMembers) + operators := []chain.Address{"A", "B", "C"} + + // #nosec G404 (insecure random number source (rand)) + // Deterministic RNG is sufficient for deterministic unit tests. + rng := rand.New(rand.NewSource(1)) + + usedOperators, skippedTries, ok := excludeOperatorTriplets( + rng, + groupMembers, + 0, + operatorToSeatCount, + operators, + 2, + ) + + if ok { + t.Fatalf( + "expected no eligible triplet exclusions, got operators: [%v]", + usedOperators, + ) + } + + if skippedTries != 0 { + t.Fatalf( + "expected zero skipped tries when no triplet is eligible, got: [%d]", + skippedTries, + ) + } +} + func isSubset( t *testing.T, groupMemberRandomizer groupMemberRandomizer,