feat(tbtc/node): FROST/ROAST Go node — distributed DKG, interactive signing loop, Taproot wallet lifecycle#3866
feat(tbtc/node): FROST/ROAST Go node — distributed DKG, interactive signing loop, Taproot wallet lifecycle#3866mswilkison wants to merge 567 commits into
Conversation
## Summary - cut over the `frost_tbtc_signer` bootstrap path to return coarse tbtc-signer signature output on successful `RunDKG -> StartSignRound -> FinalizeSignRound` - keep legacy signing fallback only for verified coarse-path failures (bridge errors, decode failures, or structural divergence) - wire `BuildTaprootTx` through the transitional native tbtc-signer orchestration path - gate `BuildTaprootTx` signing substitution on strict native-vs-legacy transaction input/output equivalence checks - add coarse success/fallback telemetry and observer-registration guards - expand unit and integration coverage for coarse cutover, retry/attempt-variation behavior, and `BuildTaprootTx` substitution safety ## Stack Context - base branch: `feat/frost-schnorr-migration-scaffold` (`#3866`) - recommended review order: 1. review `#3866` for scaffold/runtime seams 2. review this PR as the cutover + hardening delta ## Review Guide (hot paths) - coarse cutover + fallback semantics: - `pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go` - `pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go` - `BuildTaprootTx` wiring and substitution gating: - `pkg/tbtc/wallet.go` - `pkg/tbtc/native_tbtc_signer_build_taproot_tx_frost_native_tbtc_signer.go` - `pkg/bitcoin/transaction_builder.go` - coverage for tx assembly/substitution and bridge safety: - `pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go` - `pkg/bitcoin/transaction_builder_test.go` - `pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go` ## Scope Boundaries - in scope: bootstrap/coarse-path cutover hardening and safe `BuildTaprootTx` integration - out of scope: full production signer-runtime replacement and later migration phase gates
|
Readiness evidence update for the tBTC Schnorr FROST/ROAST migration stack, 2026-05-20. From a clean worktree at PR head go test -timeout 20m -tags 'integration frost_native frost_tbtc_signer' ./pkg/frost/... ./pkg/tbtcObserved package coverage:
This narrows the keep-core evidence gap for FROST/tBTC focused package behavior, but it is not a production-readiness substitute for full keep-core integration/testnet coverage. The following remain open blockers for the tBTC FROST/ROAST readiness gate:
The corresponding tBTC evidence docs were pushed in tlabs-xyz/tbtc#402. |
… at init (#3958) ## Summary Addresses three FFI-safety findings from an independent review of #3866: - **H3 (init-time panic)**: `RegisterNativeExecutionFFISigningPrimitiveForBuild` and `registerNativeExecutionAdapterForBuild` (frost_native) panic on registration failure. Both are invoked from `pkg/frost/signing/native_adapter_registration.go`'s package `init()`, so a transient registration failure crashes the binary at startup. Downstream code (`pkg/frost/signing/backend.go`) already returns `ErrNativeCryptographyUnavailable` when no native adapter is registered, so the legacy execution backend remains the safe-by-default path — panicking at init turned a recoverable degradation into an outage. Replace panics with structured `logger.Warnf` plus a package-level `lastRegistrationError` and `LastNativeRegistrationError()` accessor. Callers that want to fail startup on a registration error can opt in by checking that accessor after `RegisterNativeExecutionAdapterForBuild`; default callers continue booting with the legacy backend, exactly as if `frost_native` was never enabled. The existing `TestRegisterNativeExecutionFFISigningPrimitiveForBuild_ProviderErrorPanics` becomes `..._ProviderErrorIsRecordedNotPanicked` and asserts the new behavior. - **M1 (nil ptr free)**: `parseBuildTaggedTBTCSignerResult` unconditionally deferred `C.tbtc_signer_free_buffer(result.buffer.ptr, result.buffer.len)` even when the C wrapper's status-code -1 path returned `result.buffer.ptr == NULL`. The C wrapper checks the `frost_tbtc_free_buffer` symbol for NULL but does not check the buffer pointer, so a future Rust-side change that dereferenced its ptr argument without a NULL guard would crash. Skip the defer when `result.buffer.ptr == nil`. - **M6 (unbounded length)**: `unmarshalSignerMaterialFromPersistence` accepted any uvarint length within the data buffer. A corrupted state file or hostile peer carrying a multi-hundred-MiB envelope would allocate that many bytes before the existing bounds check ran. Cap the format length at 256 bytes and the payload length at 256 KiB — comfortably above any real signer material envelope — and reject earlier with a clear error. New regression tests `TestUnmarshalSignerMaterialFromPersistence_RejectsOversizedFormatLength` and `..._RejectsOversizedPayloadLength`. ## Out of scope (deferred) The remaining placeholder-fencing findings from the same review (H1: \`KeyGroupSource == \"legacy-wallet-pubkey\"\` fallback; H2: DKG placeholder participant pubkeys; H4: silent key-group substitution when source is legacy) require maintainer policy alignment on whether to gate the \`frost_tbtc_signer\` build behind an opt-in flag or refuse-by-default. Not included here. Several MED findings around Bitcoin witness preservation, FROST message channel back-pressure, and replay-error string matching also require behavior decisions and are not included in this safety-hygiene slice. ## Verification Local (GOCACHE under \`/private/tmp\`): - \`go test ./pkg/frost/...\` — PASS - \`go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/...\` — PASS - \`go test ./pkg/tbtc -run 'TestUnmarshalSignerMaterial|TestMarshalSigner|TestSignerMarshalling|TestFuzzDecodeNativeSignerMaterial'\` — PASS - \`go test -tags 'frost_native frost_tbtc_signer' ./pkg/tbtc -run 'TestConfigureFrostSigningBackend|TestNewNode_ConfiguresFrostSigningBackend|TestSigningExecutor_Sign|TestRegisterSignerMaterialResolverForBuild'\` — PASS - \`go vet ./pkg/frost/... ./pkg/tbtc\` — clean
… message hygiene (#3959) ## Summary Bundles four findings from the independent PR #3866 review that all sit in the same code seam (frost_native scaffold path + receive loops). Stacked on #3958. ### H1+H4 — scaffold key-group must be opt-in (was silently accepted) \`signer_material_resolver_build_frost_native_tbtc_signer.go\` built signer material with \`KeyGroupSource: \"legacy-wallet-pubkey\"\` (a sha256 placeholder, not a DKG output) and the FFI primitive in \`native_ffi_primitive_transitional_frost_native.go\` silently substituted the Rust signer's RunDKG key group when the source was that placeholder. Production deployments with placeholder material would have signed through whatever key group the Rust side returned without operator-facing signal. Add a refuse-by-default opt-in: \`KEEP_CORE_FROST_TBTC_SIGNER_ACCEPT_SCAFFOLD_KEY_GROUP=1\`. The new \`signing.AcceptScaffoldKeyGroupEnabled\` helper is per-call (not cached), so flipping the env unset recovers fail-closed behavior without restart. Both the resolver and the FFI primitive check the flag; both refuse with a clear error that names the env var and the placeholder source. New regression test pins the refuse-by-default path; existing scaffold-using tests opt in via \`t.Setenv\`. ### M2+M3 — Bitcoin witness restoration refuses unsupported shapes \`ReplaceUnsignedTransaction\`'s restoration path handled only single-element previous witnesses (P2WSH redeem script). Multi-element witnesses (P2TR script-path) were silently dropped. Replace with an explicit switch: 0 elements → leave empty, 1 → restore as before, ≥2 → fail loudly. Removes the tautological inner \`len(replacedInput.X) == 0\` checks that the outer refusals already guarantee. New regression test \`TestTransactionBuilder_ReplaceUnsignedTransaction_RejectsMultiElementPreviousWitness\`. ### M5 — first-write-wins on peer messages Three round-message receive loops (tbtc-signer contribution, FROST round one, FROST round two) did last-write-wins, letting a peer mutate its own contribution after first send. Switch to first-write-wins with byte-equal retransmissions idempotent and conflicting retransmissions logged via a new \`protocolLogger\` channel. Three message-equality helpers cover the three message types. ## Out of scope (deferred to separate PRs) - **H2** — DKG placeholder participant pubkeys (\`buildTaggedTBTCSignerDKGPlaceholderPublicKeyHex\`) needs either wiring real \`MembershipValidator\` pubkeys through or fencing under the same env flag. - **M4** — ROAST-compliant bounded transition evidence for the non-blocking message channel. Multi-PR effort. - **M7** — Real ROAST-aware retry replacing the byte-identical tECDSA shuffle in \`pkg/frost/retry/retry.go\`. Multi-PR effort. - **L5** — FFI status-code semantics for replay detection. Paired with a tbtc-signer follow-up. ## Verification Local (GOCACHE under \`/private/tmp\`): - \`go test ./pkg/frost/... ./pkg/bitcoin\` — PASS - \`go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/... ./pkg/bitcoin\` — PASS - \`go test -tags 'frost_native frost_tbtc_signer' ./pkg/tbtc -run 'TestConfigureFrostSigningBackend|TestNewNode_ConfiguresFrostSigningBackend|TestSigningExecutor_Sign|TestRegisterSignerMaterialResolverForBuild|TestBuildTaggedTBTCSignerRoundKeyGroup|TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive|TestTransactionBuilder_ReplaceUnsignedTransaction'\` — PASS
…3962) ## Summary Adds **RFC-21** as the design doc that scopes the M4 (transition evidence) and M7 (ROAST-aware retry) findings from the independent review of #3866 into a single layered design and a phased, PR-sized implementation plan. This PR is **doc-only**. It introduces no behaviour change. Subsequent implementation PRs reference RFC-21 in their descriptions. Stacked on #3961. ## Why one design, not two M4 and M7 share the same notion of *attempt context* and *transition evidence*: - Fixing M4 alone produces evidence that no consumer reads. - Fixing M7 alone gives the consumer nothing to drive retry decisions on. The RFC treats them as one design split into linear phases. ## Phasing - **Phase 0** -- this RFC. - **Phase 1** -- `AttemptContext` type + canonical hash; protocol messages carry attempt-context binding (optional during migration). - **Phase 2** -- receiver overflow tracking (M4 layer A) plumbed through the three `select { default }` drop sites, default no-op. - **Phase 3** -- coordinator state machine: `BeginAttempt`, `RecordEvidence`, `NextAttempt`. Deterministic `(AttemptContext, TransitionEvidence) -> AttemptContext` map. - **Phase 4** -- wire receiver to coordinator behind `frost_roast_retry` build tag. - **Phase 5** -- retry adapter + `EvaluateRoastRetryForSigning`; migrate first call site behind the build tag with readiness-gate guard. - **Phase 6** -- migrate remaining call sites; delete the byte-identical-to-tECDSA shuffle once unused. - **Phase 7** -- flip the readiness manifest to `present` once Phase 6 ships and integration tests run against a real testnet (only then; no early flip). ## Open questions called out explicitly The RFC lists four open design questions that need cross-team review before Phase 3 lands: 1. Cross-process coordinator agreement -- gossip topic choice. 2. Persistence across signer restart. 3. FFI surface (Rust signer error-code style; follows the L5 pattern from #425 / #3961). 4. Backward-compat horizon for the `AttemptContextHash` field. ## Out of scope - DKG retry (separate RFC). - Bitcoin transaction-builder changes. - Operator UX changes (CLI, dashboards) -- land alongside Phase 5/6. - Cross-domain ROAST between keep-core and tbtc-signer. ## Test plan - [ ] Reviewer reads RFC end-to-end. - [ ] Reviewer flags any phase that should be split further or reordered before Phase 1 begins. - [ ] Reviewer answers the four open questions or marks them defer-to-Phase-3. No code change in this PR, so no CI test run is meaningful beyond asciidoc rendering.
…ild tag (#3965) ## Summary Forward-fix for #3866 CI: the Phase 1B binding file and test referenced message types defined in \`//go:build frost_native\` files but were themselves untagged. Untagged staticcheck on the integration branch (#3866) then reported \`undefined: nativeFROSTRoundOneCommitmentMessage\` and the client-lint job failed. Adds \`//go:build frost_native\` to: - \`pkg/frost/signing/attempt_context_binding.go\` - \`pkg/frost/signing/attempt_context_binding_test.go\` The helpers and tests are only exercised by gated code paths (the three message-type methods all live behind \`frost_native\`), so the build tag is the right locus. ## Why now PRs #3963 (Phase 1A) and #3964 (Phase 1B) were merged into the \`feat/frost-schnorr-migration-scaffold\` branch before #3866's integration CI ran. Once the merges landed, #3866's \`client-lint\` job rebuilt under the untagged staticcheck pass and exposed the missing tag. This PR is the smallest possible fix. ## Verification Locally with module-pinned staticcheck 2025.1.1: \`\`\` go build ./... go build -tags 'frost_native frost_tbtc_signer' ./pkg/frost/... go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/signing/ staticcheck -checks \"-SA1019\" ./... # whole repo, silent staticcheck -checks \"-SA1019\" ./pkg/frost/signing # silent \`\`\` ## Test plan - [ ] CI green: client-lint, client-vet, client-scan, client-build-test-publish all pass. - [ ] #3866 lint job recovers once this merges into \`feat/frost-schnorr-migration-scaffold\`.
…3988) ## Summary Closes the **M4 gap** from the original PR #3866 review by adding the two evidence categories the RFC-21 Phase-2 work left as future work: **validation-rejection evidence** and **first-write-wins-conflict evidence**. With this PR, the \`NextAttempt\` policy can permanently exclude misbehaving peers on all four ROAST blame channels -- transport-overflow, validation-reject, equivocation-conflict, and silence -- instead of just overflow + silence. ## Why this matters A peer that only sends **malformed messages** (validation rejects, never overflows the channel) was previously indistinguishable from a silent peer. The transient silence-parking policy would bench-and-reinstate them indefinitely, never permanently excluding the malicious behaviour. Same for a peer **equivocating mid-attempt**: the existing first-write-wins assembly correctly dropped the conflicting retransmission but only logged the event -- the bundle carried no structured evidence the coordinator's policy could act on. ## What lands ### Recorder API | Surface | Notes | |---|---| | \`RecordReject(sender, reason)\` | reason captured verbatim; per-reason quota counter | | \`RecordConflict(sender)\` | saturates at conflict quota | | \`RejectQuotaDefault = 8\`, \`ConflictQuotaDefault = 4\` | matches RFC-21 Layer A categoryQuota | | Per-reason quotas independent | peer cannot saturate one reason to mask another | ### Wire types | Type | Sort order | Cap | |---|---|---| | \`RejectEntry{Sender, Reason, Count}\` | asc by Sender, then asc by Reason | per-attempt evidence size bounded by Σ quotas | | \`ConflictEntry{Sender, Count}\` | asc by Sender | per-attempt evidence size bounded by Σ quotas | Both fields use \`omitempty\` so pre-PR snapshots round-trip without the new fields. \`Validate()\` enforces sorted-ascending invariants. ### NextAttempt policy | Threshold | Value | Source | |---|---|---| | \`RejectExclusionThreshold\` | 1 | RFC-21 Layer B ("any non-transport reject is sufficient cause") | | \`ConflictExclusionThreshold\` | 1 | A single conflict is byzantine evidence | \`computeNextAttempt\` merges \`overflowBlamed\`, \`rejectBlamed\`, \`conflictBlamed\` into the permanent ExcludedSet. The \`blamedSenders\` helper is factored out so all three categories share the deterministic sort + threshold-comparison logic. ### Receive-loop wiring Three reject sites and three conflict sites updated across the two files that house the three FROST/tbtc-signer receive loops: | Site | Was | Now | |---|---|---| | \`shouldAcceptNativeFROSTMessage\` returns false | silent drop | \`evidence.RecordReject(senderID, "validation_gate_rejected")\` + drop | | First-write-wins conflict in assembly loop | warn log only | \`evidence.RecordConflict(senderID)\` + warn log | ## Test coverage (15 new cases) - 7 recorder tests: accumulation, per-reason quota saturation, per-reason independence, conflict saturation, all-categories-present, NoOp-inert, RFC-constant assertions - 5 policy tests: single reject excludes, single conflict excludes, reject+conflict on different senders, empty evidence (sanity), threshold-constant assertions - Receive-loop wiring is covered indirectly by the recorder unit tests; the NoOp default keeps pre-RFC-21 receive semantics observably unchanged so no integration-level test is required. ## Verification | Command | Result | |---|---| | \`go build ./...\` + \`go build -tags 'frost_native frost_tbtc_signer frost_roast_retry' ./...\` | both clean | | \`go test ./pkg/frost/...\` + race | pass | | \`go test -tags 'frost_native frost_tbtc_signer frost_roast_retry' ./pkg/frost/...\` | pass (5 packages) | | \`staticcheck -checks '-SA1019' ./pkg/frost/...\` | silent | | \`go vet ./pkg/frost/...\` + \`gofmt -l ./pkg/frost/\` | clean | ## RFC-21 status With this PR, all four ROAST evidence categories are operational. M4 from the original PR #3866 review is **fully closed**. The keep-core code arc for RFC-21 is now feature-complete; remaining work is operations-side (integration testnet, manifest flip). ## Test plan - [ ] CI green. - [ ] Reviewer confirms the per-reason quota independence is the right semantics (alternative: single per-sender reject counter). - [ ] Reviewer confirms threshold = 1 for both reject and conflict (alternative: higher to absorb noise; trade-off is faster vs slower exclusion of misbehaving peers).
#3993) ## Why The RFC-21 Phase 6 review decided which orchestration errors are fallback-eligible (static config errors → safe to fall back to legacy retry path) and which must hard-fail (runtime per-attempt errors → no fallback, since per-participant divergence creates split-brain group fracture). The rationale lived in commit messages, the RFC text, and inline comments on individual sentinels — distributed enough that a future maintainer reading just \`roast_retry_orchestration.go\` could miss the load-bearing constraint. This PR adds a top-of-file design-rationale block that centralises the decision in the place that enforces it. ## What changed - One file changed: \`pkg/frost/signing/roast_retry_orchestration.go\` - Pure documentation: no behavior change, no test changes, no API change - 49 lines added (one comment block) ## What it captures 1. **STATIC vs RUNTIME classification** — explicit definitions, with the sentinel (\`ErrNoRoastRetryCoordinatorRegistered\`) and detection mechanism (\`errors.Is\` in \`signing_loop_roast_dispatcher.go\`) named. 2. **Why static-error fallback is safe** — every honest signer observes the same node-local config at startup, so the fallback decision is deterministic across the group. 3. **Why runtime-error fallback is unsafe** — per-attempt protocol state errors can be observed by some participants and not others within the same attempt; fallback would put some operators on new code and others on legacy for the same attempt. 4. **Enforcement rule** — any error surfaced from this package that is intended to permit fallback MUST be the sentinel; wrapping ANY runtime error in the sentinel is a safety regression that PR reviewers should reject. 5. **Historical redirect** — the earlier design had \`BeginAttempt\` failures fall back, on the assumption that BeginAttempt was cheap idempotent setup. Review identified that BeginAttempt mutates per-attempt state and can fail from races with concurrent receives; the taxonomy was tightened so only true configuration errors are fallback-eligible. ## Lineage Surfaced in the cross-PR review re-evaluation following PR #3866 follow-up landings. Originally tracked as "Document static-vs-runtime classification canonically" — initially flagged as "available if you want," now elevated because the rationale was the most important architectural decision in the RFC-21 stack and is currently the easiest piece of design context to lose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary - add FROST WalletRegistry and FrostDkgValidator bindings plus config and chain attachment - implement v4 FROST DKG result digest assembly with full vs active member types and fixture-backed parity tests - add the native FROST DKG engine boundary, P2P round protocol, result signing, coordinator lifecycle, challenge monitoring, and wallet ID handling for x-only output keys ## Notes - Stacked on #3866 / `feat/frost-schnorr-migration-scaffold`. - Runtime DKG still requires the concrete native DKG engine registration from the frost-uniffi-sdk UDL/Rust export work. - The digest fixture now records the tBTC TypeScript generator source and regeneration command. A paired tBTC PR should still commit the mirror fixture at `docs/test-vectors/frost-dkg-result-digest-v1.json` and add the TS-side emitter/test; until then, the keep-core test verifies the pinned bytes and metadata but does not compare against a checked-in tBTC mirror file. ## Validation - `go test ./pkg/frost/registry ./pkg/chain/ethereum ./pkg/chain/ethereum/frost/gen/...` - `go test ./pkg/tbtc -run "TestFrostDKGSignatureThreshold|TestBoundedFrostDKGRecoveryStartBlock|TestFrostDKGRecoveryLookBackBlocks" -count=1` - `go test -tags "frost_native frost_tbtc_signer" ./pkg/tbtc -run "TestLowestLocalActiveMemberIndex|TestFrostMisbehavedMemberIndices|TestFrostDKGSignatureThreshold|TestBoundedFrostDKGRecoveryStartBlock|TestFrostDKGRecoveryLookBackBlocks" -count=1` - CI `client-build-test-publish` passes on the prior pushed commit; rerunning for the latest follow-up commit after push. ## Local Note - Full local `go test ./pkg/tbtc` currently fails in standalone `TestWatchCoordinationWindows`; this reproduces when run by itself and appears unrelated to the FROST DKG coordinator changes.
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
## Summary Stacked on #3866. This PR implements Taproot-native key-path wallet signing for the FROST migration path. It adds P2TR script handling, BIP341 SIGHASH_DEFAULT computation, BIP340 Schnorr signature verification, and single-element Taproot witness application in the Bitcoin transaction builder. The wallet transaction executor now routes all-P2TR transactions through the Schnorr/Taproot witness path. Mixed Taproot plus legacy inputs are rejected before signing, so this does not introduce a dual-signing model. ## Details - Add P2TR script helpers and x-only output key extraction. - Add Taproot key-path sighash generation without a repo-wide btcd upgrade. - Add `AddTaprootKeyPathSignatures` for 64-byte BIP340 signatures. - Preserve canonical 32-byte FROST signing messages when `big.Int` strips leading zero bytes. - Add builder and wallet tests covering all-P2TR signing and mixed-input rejection. ## Validation - `go test ./pkg/bitcoin ./pkg/tbtc` - `go test -tags=frost_native ./pkg/frost/signing`
…4026) Stacked on #3866 (base: `feat/frost-schnorr-migration-scaffold`). ## What Adds `TestSelectCoordinator_CrossLanguagePinnedVectors` to `pkg/frost/roast/coordinator_test.go`, pinning concrete `SelectCoordinator` outputs for fixed `(members, seed, attempt)` tuples. ## Why The Rust signer (PR #4005) ports Go's `math/rand` shuffle semantics in `pkg/tbtc/signer/src/go_math_rand.rs` and pins exact expected coordinators in `select_coordinator_matches_known_keep_core_vectors` (seed `6879463052285329321`). The Go suite, however, only asserted *properties* (determinism, input-order independence, seed/attempt sensitivity) — never concrete outputs. That asymmetry means a Go-side semantic change (e.g. migrating to `math/rand/v2`, changing the shuffle, or altering the `attemptSeed + attemptNumber` composition) would pass the entire Go suite while silently breaking coordinator agreement with the Rust engine — a network-fracturing liveness failure that would only surface in mixed-version soak testing. This PR pins the exact same vectors as the Rust test (verified locally that current Go code produces them), plus pins the previously value-free `(seed=333, attempt=4)` case to its concrete result. Either side drifting now fails its own unit suite. ## Review note (no code change) While verifying parity I noticed the two layers derive the legacy `int64` shuffle seed differently today: - Go RFC-21 layer: `foldAttemptSeed(SHA256(DkgGroupPublicKey || SessionID || MessageDigest))` (first 8 bytes, BE), 0-based `AttemptNumber`. - Rust engine strict-mode validation (`roast_attempt_seed_from_message_digest_hex`): first 8 bytes of the **raw message digest**, with a 1-based `attempt_number`. Not a live bug — keep-core does not yet send `attempt_context` over the FFI, and Rust strict mode is opt-in — but when a later phase wires RFC-21 attempt contexts into the Rust engine's `validate_attempt_context`, the two expected-coordinator computations will disagree unless one side is aligned first. Flagging so it lands on the integration checklist rather than in a testnet incident.
…erve Codex P2: the ROAST session-keyed registries collide across wallets. roastSessionID (transition-record registry) and signingSessionID (session-handle registry) are derived from message/taproot-root/start-block -- NOT the wallet key group -- so on a node controlling two FROST wallets that reuse the same 1..N member index, two wallets signing the SAME message (both key-path, nil merkle root -> the id doesn't even bind the wallet) share a session id. Selection then loads the sibling wallet's transition record and drives NextAttempt with the wrong handle/bundle, and the session-handle binding can be overwritten -- breaking ROAST retry for the colliding wallet. Fix at the source: fold this wallet's FROST key group into BOTH session-id functions, computed once (roastSelectorKeyGroupID(se.signers[0])) and shared by the stable roastSID and every per-attempt signingSessionID. The key group is group-uniform, so the ids stay identical across a wallet's honest nodes while being disjoint between two wallets on one node. This wallet-scopes every session-keyed ROAST registry at once (transition record, session-handle, interactive-engine namespace) rather than threading keyGroupID through each registry's callers. Empty for legacy/non-native wallets (which do not drive ROAST). Reuses the same handle setRoastKeyGroupID installs, so selection scoping and session scoping never disagree. Also (approved review nit): key-group-scope ObserveAttemptForTransition's activation gate (RoastRetryActiveForKeyGroupMember) so ALL fracture-sensitive gates -- selection, active-attempt numbering, transition exchange, observe -- are per-wallet, not seat-only. Tests: roastSessionID and signingSessionID both bind the key group (a different key group yields a different id in both the key-path and taproot branches); the nil-root signing id's hardcoded expectation updated for the new trailing field. go test -race (frost/signing) + tbtc + default build green; all build-tag combos compile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Htk7v5FLAZrYwEkw88Dv9
…ber predicate Every ROAST activation gate is now wallet-scoped (RoastRetryActiveForKeyGroupMember): participant selection, active-attempt numbering, the transition exchange, and observe. That left RoastRetryActiveForMember -- the seat-only "active for this member under ANY key group" predicate -- with zero production callers. Its existence is a liability: it is precisely the seat-only check that, on a node controlling two wallets reusing a 1..N member index, would make a sibling wallet look active and desync attempt/session numbering (the fracture the key-group gates fix). Delete it so it cannot be wired back onto a fracture-sensitive path. - Remove RoastRetryActiveForMember and its dedicated test; the key-group variant is covered by TestRoastRetryActiveForKeyGroupMember_ScopesByKeyGroup. - Keep the underlying seat-only registry scan RegisteredRoastRetryCoordinatorForMember (test-only now) -- it is a registry-membership query, not an activation gate -- but document that no production path may use it and why. - Update the readiness/registration doc comments accordingly. No functional change. go test -race (frost/signing) + tbtc + default build green; all build-tag combos compile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Htk7v5FLAZrYwEkw88Dv9
…igner limit Codex P1: the prior commit made roastSessionID/signingSessionID wallet-unique by CONCATENATING the FROST key group onto the key-path (nil merkle root) branch. Persisted tBTC signer key groups are 64/66 hex chars, so a key-path id became ~140+ chars -- over the native signer's 128-char session-id limit. driveInteractiveRoastSigningIfEnabled passes attemptCtx.SessionID (== roastSessionID) to the interactive engine via NewActiveRoastAttempt -> InteractiveSessionOpen, so key-path FROST signing would be rejected before it could run. (The taproot branch already hashed for exactly this reason; only the key-path branch regressed.) Hash the key-path branch too, including the key group in the digest, so the id length is constant (~70 chars) regardless of key-group length. Tags keep the four id shapes disjoint: roast-kp- / roast-tr- (stable ROAST id) and kp- / tr- (attempt-specific id); roastSessionID still binds message+startBlock+keyGroup, signingSessionID still binds message+attempt+keyGroup (start block was never part of the attempt-specific id). Tests: dedicated within-limit regressions for BOTH key-path branches using a full-length (66 hex) key group assert len <= 128, plus the existing message/attempt/startBlock/ key-group binding checks (now against the hashed form). go test -race (frost/signing) + tbtc + default build green; all build-tag combos compile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Htk7v5FLAZrYwEkw88Dv9
…igning (#4139) ## What Wires `RegisterRoastRetryCoordinator` into the live signing loop so the interactive FROST (ROAST/RFC-21) signing path actually runs on a production node, and fixes a multi-seat aggregation collision found while proving it live. Stacked on top of #4138 (coarse-path deletion). ## Why Before this change `RegisterRoastRetryCoordinator` had **no production caller** — it was only ever registered from tests. On a live node the executor entry's `BeginOrchestrationForSession` looked up a coordinator per member, found none, and the interactive drive fell through. With the coarse path deleted (#4138), interactive-only signing therefore failed closed: a FROST wallet could not sign. ## What changed - **`node.getSigningExecutor`** now calls `registerRoastRetryCoordinatorForSeats(n, signers)` once per wallet, after the executor-cache early return and before the executor is used. - **`signing_loop_roast_coordinator_registration_frost_native_roast_retry.go`** (`//go:build frost_native && frost_roast_retry`): builds an operator-key `roast.Signer` and one per-seat in-memory coordinator, registering each via `RegisterRoastRetryCoordinatorForMember`. Mirrors the `newRoastTransitionController` default/tagged split. - **`signing_loop_roast_coordinator_registration_default.go`**: no-op under all other build tags so `getSigningExecutor` can call it unconditionally. ### Multi-seat aggregation fix A multi-seat operator runs one interactive runner per **local** seat against the **shared** per-process engine session. Both seats reach runner step 9 and try to aggregate: the first succeeds, the second hits the engine's per-attempt anti-replay marker (`InteractiveAttemptAlreadyAggregated`) even though the deterministic signature already exists. The runner now memoizes the aggregation per `(session, attempt)` so sibling local seats share the first result. `aggregateOnce` is injectable (defaulting to the process-global memo) so unit tests that simulate several operators in one process aggregate against their own engines; the memo's dedup contract is pinned by `TestAggregateInteractiveOnce_*` (incl. a `-race` concurrency case). ## Verification - `go test -race -tags "frost_native frost_roast_retry" ./pkg/frost/signing/ ./pkg/tbtc/` — green. - Full cgo node binary (`frost_native frost_tbtc_signer frost_roast_retry cgo`) links. - **Live on the anvil + Bitcoin testnet4 FROST rehearsal**: one node held seats 2 and 3; the signing subset was `{2,3}` (coordinator 2, member 1 excluded); both seats produced the **identical** BIP-340 signature with **zero** `InteractiveAttemptAlreadyAggregated` errors — proving the multi-seat path end-to-end. ## Known follow-ups (separate PR) - The `SignatureVerifier` is still a **no-op**. The happy path never verifies evidence signatures (`BeginAttempt → InteractiveAggregate → MarkSucceeded` touches neither Signer nor Verifier), so a valid signature is produced without it — but the retry/blame path needs a real member-keyed verifier (`member index → operator public key → VerifyWithPublicKey`), flipped from no-op to real atomically across the operator set. - The retry registry is keyed by **member index alone**, so this wiring is correct only while a node controls a **single** FROST wallet (the current experimental deployment). A wallet-scoped registry key is a prerequisite for multi-wallet FROST. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011Htk7v5FLAZrYwEkw88Dv9
… layer (#4140) ## What Replaces the no-op `roast.SignatureVerifier` wired in #4139 with a **real member-keyed verifier**, so the ROAST retry/blame path actually authenticates evidence. Stacked on #4139. ## Why With a no-op verifier, a valid-looking `LocalEvidenceSnapshot` or `TransitionMessage` from **any** source was accepted — the coordinator could not tell whether a signature really came from the operator seated at the claimed member index. The happy path never verifies evidence signatures, but the retry/blame path's integrity depends on this check. ## Design Mirrors the existing FROST DKG-result-signing verification (`frost_dkg_result_signing.go`). The node knows each seat only by operator **address** (`wallet.signingGroupOperators`), while `chain.Signing.VerifyWithPublicKey` needs the public key. So: - **`operatorKeyRoastSigner`** now emits a self-describing envelope — `[version][pubKeyLen][publicKey][signature]` — carrying the signer's operator public key alongside the raw operator-key signature. - **`memberKeyedRoastSignatureVerifier`**: 1. decodes the envelope, 2. binds the carried public key to the claimed seat via `PublicKeyBytesToAddress(pubKey) == signingGroupOperators[member-1]`, 3. verifies the raw signature against that public key with `VerifyWithPublicKey`. All three must hold, so a signature valid under the **wrong** seat's key — or a key not seated at the claimed member — is rejected. The envelope is a private contract between the Signer and Verifier; the coordinator treats signatures as opaque bytes (per `pkg/frost/roast`). ## Deployment note (atomic flip) The switch is **inert on the happy path** (`BeginAttempt → InteractiveAggregate → MarkSucceeded` touches neither Signer nor Verifier) but must be adopted by the whole operator set at once: an upgraded verifier rejects a peer that still emits bare (non-enveloped) signatures during a retry. This is fine for the current single-wallet experimental deployment. ## Tests Real secp256k1 operator crypto via `local_v1` (not stubs): - envelope round-trip + malformed/empty rejection; - verifier accepts the correct seat; rejects wrong-seat attribution, an out-of-group key, a tampered payload, an out-of-range member index, and a bare signature; - **integration through a real `roast` coordinator's `RecordEvidence`**: a valid seat snapshot is recorded; a snapshot signed by the wrong operator is rejected with `roast.ErrSignatureInvalid`. `go test -race -tags "frost_native frost_roast_retry" ./pkg/tbtc/ ./pkg/frost/signing/` — green. All build-tag combos compile; full cgo node binary links. ## Remaining follow-up Still keyed by member index alone, so single-wallet-per-node remains a prerequisite for multi-wallet FROST (tracked separately). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011Htk7v5FLAZrYwEkw88Dv9
## What Keys the ROAST-retry coordinator registry by **(wallet key group, seat)** instead of seat index alone, removing the single-wallet-per-node limitation flagged in #4139, and scopes every coordinator resolution — including retry **selection** — to the right wallet. Stacked on #4140. ## Why The registry was keyed by `group.MemberIndex` (1..N) alone. Every FROST wallet's group reuses those indices, so on a node controlling more than one FROST wallet, registering wallet B's seat 1 **overwrote** wallet A's seat 1 — and wallet A then aggregated its attempt with wallet B's coordinator (bound to the wrong key group and verifier). Latent today (single FROST wallet), but a correctness bug for multi-wallet FROST. ## Design - **`RoastRetryDeps` carries `KeyGroupID`** (`AttemptContext.KeyGroupID`, the tbtc-signer `KeyGroup` handle). Registration keys by `(deps.KeyGroupID, member)`, so the register API keeps its signature and legacy/test callers default to `""`. - **Authoritative lookup is wallet-scoped**: `RegisteredRoastRetryCoordinatorForKeyGroupMember(keyGroupID, member)`. Every site that actually *uses* a coordinator has the key group in hand — the interactive drive and orchestration via `AttemptContext.KeyGroupID`; observe and the transition controller via the request's signer material (new exported helper `KeyGroupIDFromSignerMaterial`); and the retry **selector** via the transition record's `PreviousContext.KeyGroupID`. The per-wallet fracture-avoidance count (`registeredRoastRetryMemberCount`) is likewise key-group-scoped. - **Only the boolean activation gates stay seat-only**: `RoastRetryActive` / `RoastRetryActiveForMember` and the selector's partial-registration pre-check answer "is ROAST active for this seat *anywhere*" without needing a key group. They never supply the coordinator that drives aggregation/`NextAttempt` — that always comes from the wallet-scoped lookup — so a seat-only match cannot cause a wrong-wallet coordinator to be used; the worst case is a fail-closed-then-legacy. - **Registration** derives the wallet's key group from the seat's native signer material; a wallet whose material has no key-group handle is left on legacy (logged) rather than registered under an unreachable key. Single-wallet behaviour is unchanged (registration and lookup agree on the one key group), so the live interactive path is behaviour-identical. ### Follow-up fix in this PR (2nd commit) Review (and Codex) caught that `ConsumeRoastTransitionForSelection` still resolved the coordinator via the seat-only scan and then called `deps.Coordinator.NextAttempt` with a handle minted by the *record's* wallet coordinator — so on a multi-wallet node the scan could name a different wallet's coordinator, whose `attempts` map lacks the handle → `ErrUnknownAttempt` → non-deterministic fail-closed. Fixed by keeping the seat-only check as a **boolean** partial-registration gate and resolving the coordinator that drives `NextAttempt` via the record's key group. This is what makes multi-wallet *retries* (not just the initial attempt) correct. ## Tests Real crypto where relevant: - **Registry isolation** across two key groups sharing a seat (the overwrite regression), per-key-group count, and the member-only scan. - **Selector isolation** (`…_UsesRecordKeyGroupCoordinator`): a decoy coordinator registered for the same seat under a *different* key group; selection must deterministically drive the record's-key-group coordinator (a wrong pick would `ErrUnknownAttempt`). - **`KeyGroupIDFromSignerMaterial`** agrees with `AttemptContext.KeyGroupID` and errors on nil/unsupported material. - The existing drive/observe/orchestration/executor/cross-node-fracture suites realigned to register under the key group their lookup path derives; the fracture test still pins the unregistered-seat vs no-record fail-closed distinction. `go test -race -tags "frost_native frost_roast_retry" ./pkg/frost/signing/ ./pkg/tbtc/` — green. All build-tag combos compile; full cgo node binary links. Live single-wallet path re-verified on the anvil + Bitcoin testnet4 rehearsal (heartbeat FROST signature, zero aggregation collisions, zero key-group-derivation warnings). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011Htk7v5FLAZrYwEkw88Dv9
Resolve the TestWatchCoordinationWindows conflict by taking main's channel-driven send/expect rewrite (#4006) wholesale — it supersedes this branch's earlier receivedWindows-based flake fix — and dropping the now-unused sort import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stack 1 of 3 on top of #3866. Followed by #4145 and #4146. Addresses the FROST DKG configuration and coordinator findings from the latest review: - load `groupSize`, `activeThreshold`, and `groupThreshold` from `FrostDkgValidator` independently of the legacy ECDSA validator - use the FROST parameters for DKG execution, result signing, readiness, signer registration, recovery lookback, wallet signing, and inactivity executors - replace eager seven-day FROST event deduplication with atomic in-progress leases that are released after transient failures and completed only after work is handled or safely scheduled - when a stale DkgStarted replay resolves a newer event, complete the stale lease and acquire a fresh lease for the newer seed before membership or DKG execution - guard nil native DKG parameters and treat build-level native unavailability as terminal for the current process while keeping runtime-changeable gates retryable - stop result approval immediately when the parent context is canceled Startup now intentionally fails if `FrostWalletRegistry` is enabled without a configured `FrostDkgValidator`, because the node cannot safely infer the independent FROST thresholds. Verification: - `go test ./pkg/tbtc ./pkg/chain/ethereum` - focused default and `frost_native` coordinator/parameter tests - focused race tests and `go vet` - stale-to-latest seed regressions covering concurrent, completed, and transient-failure cases The next stack layer, #4145, routes heartbeat, inactivity, and closure handling through the wallet's scheme-specific registry.
## Stack 2 of 3. Depends on #4144, is followed by #4146, and targets `fix/pr3866-dkg-review`. ## What changed - route FROST inactivity claims through FrostWalletRegistry and FrostSortitionPool, including the FROST claim hash and operator IDs - keep legacy heartbeat stake and registration checks bound to the ECDSA registry during dual-stack migration - subscribe to and recover recent FROST wallet-closure and inactivity-claim events through cancellation-aware delivery; in-flight log filters stop on unsubscribe - confirm wallet closure and remove signer material against the wallet's signing scheme - make closure deduplication scheme-scoped, retryable after failures, and safe when a replay arrives during active handling - remove the obsolete eager ECDSA-only closure helper and align local-chain FROST registration behavior with production ## Verification - `go test ./pkg/tbtc ./pkg/chain/ethereum` - `go test -tags=frost_native ./pkg/tbtc ./pkg/chain/ethereum` - focused `-race` tests for wallet-closure deduplication and FROST inactivity event delivery
## Stack 3 of 3. Depends on #4145 and targets `fix/pr3866-registry-review`. ## What changed - merge legacy and V2 wallet-registration history and deduplicate compatibility events without synthesizing zero legacy wallet IDs - estimate moving-funds fees from the resolved source and target scripts - discover native P2TR deposit sweeps through Taproot reveals and canonical wallet scripts, with an explicit error for unsupported history backends - preserve the Taproot deposit marker through sweep proposal construction - estimate P2TR sweep fees with the actual main-UTXO presence instead of assuming one exists ## Verification - `go test ./pkg/bitcoin ./pkg/maintainer/spv ./pkg/tbtcpg ./pkg/tbtc ./pkg/chain/ethereum` - `go test -tags=frost_native ./pkg/tbtc ./pkg/chain/ethereum` - independent focused review and tests for registration-history compatibility, P2TR discovery, and sweep fee shapes
## Relationship Companion integration for the full Rust signer stack #4147 → #4148 → #4149. This PR is based on the Go FROST scaffold branch from #3866 and pins final signer commit `6e0fa9741ffb6b0bb5603eabe26fb4cc1b13ecd9` (ABI 3.3). ## What changed - send every input prevout script to `BuildTaprootTx` and decode the ordered BIP-341 key-spend messages - bind the complete transaction under the exact stable ROAST session ID used by Interactive Open - compare Rust transaction bytes and the selected input sighash with the Go builder immediately before signing - carry the transaction builder through sequential batch signing after each input start block is finalized - require policy binding for Schnorr wallets and fail closed on missing key groups or unavailable native signing - skip the optional `buildtx-*` parity preflight on the mandatory Schnorr path, so the global policy rate limiter charges only the per-input build under the actual ROAST session; retain the preflight for eligible non-policy-bound Taproot callers - retain native-unavailable fallback only for the optional observational parity call - carry a closed, immutable heartbeat intent from the raw 16-byte proposal through the FFI runner to Rust Open - require ABI 3.3 and pin the complete policy/durability/rollout stack, including ABI-compatible public latency metrics - compute the cross-language expected BIP-341 sighash dynamically through the production Go builder rather than treating a literal as a btcd-derived oracle ## Design boundaries verified - Schnorr/FROST transaction signing supports all-P2TR input sets only. Mixed or legacy inputs are rejected before native binding; hybrid ECDSA/Schnorr assembly remains out of scope. - A build missing `frost_tbtc_signer && cgo` cannot start a FROST-enabled node: startup checks the actual linked native engine, so the default no-op binder cannot silently serve production. - Generic signing carries no intent. Heartbeat signing alone supplies `{type:"heartbeat",message_hex:"..."}`; Rust validates the protocol shape and digest at Open and Round2. - ABI 3.3 includes the per-wallet heartbeat throttle/metric and independently tunable canary policy-evidence minimum while preserving ABI-3 public latency field semantics. ## Verification - `go test ./pkg/frost/signing ./pkg/tbtc` - `go test -tags frost_native ./pkg/frost/signing ./pkg/tbtc` - focused one-token rate-limit and non-policy-bound substitution regressions - final pinned Rust head: `cargo test --locked` (library: 249 passed, 1 ignored; admission binary: 25 passed; BIP-341 vector: 1 passed) - final pinned Rust head: `cargo check --locked --all-targets` - final pinned Rust head: `cargo clippy --locked --all-targets -- -D warnings` - final pinned Rust head: six exact Phase-5 chaos scenarios passed - ABI 3.3 compatibility, nil-intent, JSON wire-shape, runner propagation, mixed-input rejection, unavailable-native, one-token rate-limit, substitution-compatibility, and public latency semantic regressions pass - `git diff --check`
A redemption of the wallet's full main UTXO leaves only the treasury-fee remainder as change (change = mainUtxoValue - sum(requestedAmount - treasuryFee), independent of the tx fee), which can be just a few satoshi. Modern Bitcoin nodes reject such transactions at relay time with "dust, tx with dust output must be 0-fee", and the wallet then re-signs and re-broadcasts the same non-relayable transaction at every coordination window until the requests time out. Omit a positive change below a conservative 546-satoshi dust limit so the remainder becomes part of the transaction fee; the Bridge accepts change-less redemption transactions and validates redeemer output values individually, so this is safe on-chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qj3HDu1fhX3xT5aT3pJA3s
#4143) ## Problem A live testnet4 redemption of a FROST wallet's **full main UTXO** produced a transaction that no Bitcoin node would relay. The numbers: main UTXO 24,372 sat, one pending request with `requestedAmount` 24,372 sat, `treasuryFee` 12 sat, proposal fee 110 sat. The signed transaction (`adca7f2d57ea8ccd177b86d3a9b72faf6aa43c325760efd7d7000c45e23fa807`) had two outputs: 24,250 sat to the redeemer and a **12-sat change output** back to the wallet's P2TR script. bitcoind rejected it: ``` dust, tx with dust output must be 0-fee ``` Under the modern ephemeral-dust relay policy, a transaction carrying a dust output is only relayable if it pays zero fee — which a redemption never does. The nodes then re-signed and re-broadcast the identical rejected transaction at every coordination window, burning signing rounds until the redemption request would time out on-chain. ## Why it happens In `assembleRedemptionTransaction` (`pkg/tbtc/redemption.go`) the change value is: ``` change = mainUtxoValue - Σ(requestedAmount_i - treasuryFee_i) ``` i.e. the treasury-fee portion of the redeemed amounts physically stays with the wallet. Crucially, this value is **independent of the transaction fee** — the fee is subtracted from the redeemer outputs, not from the change. So whenever redemptions consume (almost) the whole main UTXO, the change degenerates to a few satoshi and no fee choice can fix it. The assembler previously created a change output for *any* positive value. ## Fix At the single source of truth for the output list — the Go assembler whose outputs feed both the legacy signing path and the native `BuildTaprootTx` builder via `UnsignedTransactionIO()`: - Introduce `redemptionChangeDustLimit = 546` sat (conservative: actual thresholds at the default 3 sat/vB dust relay fee are 294 sat for P2WPKH and 330 sat for P2TR change; 546 is the historical P2PKH dust limit, the highest among standard output types, covering any change script with margin). - When the computed change is positive but below this limit, log a warning with the amounts and **omit the change output entirely**; the sub-dust remainder becomes miner fee. Redeemer outputs are untouched. - The resulting transaction is shaped exactly like a natural zero-change redemption, which the downstream pipeline (sighash computation, native-builder parity, broadcast) already supports — the existing "multiple redemptions without change" test scenario exercises that path. A defensive sub-dust warning in the `pkg/tbtcpg` proposal generator was considered and deliberately skipped: `ProposeRedemption` only knows the wallet public key hash, so projecting the change there would require duplicating the wallet-action main-UTXO resolution logic. The assembly-layer guard fully prevents the failure; request-selection semantics are unchanged. ## On-chain safety argument Verified against `contracts/bridge/Redemption.sol`: - **Zero-change path**: `submitRedemptionProof` accepts a transaction with no change output — when no wallet-script output is found, `outputsInfo.changeValue` stays 0 and the Bridge simply `delete`s the wallet's main UTXO hash ("no funds left on the wallet"), which is the true post-state here. - **Per-request bounds**: `processNonChangeRedemptionTxOutput` only requires each redeemer output value to satisfy `redeemableAmount - txMaxFee <= outputValue <= redeemableAmount`. Output values are unchanged by this fix, so no per-request fee cap is affected. - **Total fee cap**: the implied fee grows by the omitted change (< 546 sat), bounded far below `redemptionTxMaxTotalFee` (default 1,000,000 sat). ## Validation - New `TestAssembleRedemptionTransaction_SubDustChange` in `pkg/tbtc/redemption_test.go`, reproducing the live numbers against a P2TR wallet main UTXO: - 12-sat change → change output omitted, redeemer output identical (24,250 sat), implied fee grows 110 → 122 sat; - 545-sat change (just below limit) → omitted; - 546-sat change (at limit) → change output kept; - zero change → behavior unchanged (single redeemer output); - sighash computation succeeds for every resulting shape. - `gofmt` clean on touched files; `go vet ./pkg/tbtc/ ./pkg/tbtcpg/` clean. - `go test ./pkg/tbtc/ ./pkg/tbtcpg/` — all green (145s / 0.5s). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Preserve persisted legacy tECDSA shares on the legacy bridge in the cgo FROST build, require the complete interactive ROAST path before starting distributed DKG, and start ROAST receiving only after the first subscriber exists.
Resolve the redeeming and moved-funds wallet scripts before proposal fee estimation so P2TR inputs and outputs are sized accurately. Keep the legacy estimator entry points as P2WPKH compatibility wrappers.
Stack: 1/2, based on #3866. Downstream: #4153. Addresses the runtime-safety findings from the activation review: - keep persisted legacy tECDSA shares on the legacy bridge in the cgo FROST build; - refuse distributed FROST DKG unless both opt-ins, the ROAST transition producer, and the interactive engine are ready; - start ROAST channel receiving only after the first subscriber is installed. Regression coverage includes a persisted legacy signer round trip, missing ROAST env/build gates, synchronous receive-on-subscribe delivery, and the full activation build-tag selection. Verified with default, frost_native, frost_native+frost_roast_retry, frost_native+frost_tbtc_signer, and full activation-tag focused suites, plus the runner-bus race test.
Stack: 2/2, based on #4152 (which is based on #3866). Addresses both fee-estimation findings from the activation review: - redemption proposals resolve the wallet's canonical script and size the main input and change output with the P2TR shape for FROST wallets; - moved-funds sweep proposals resolve the actual moved-funds prevout plus the target wallet output script, including the optional main-UTXO input. The existing exported estimators remain available as legacy P2WPKH compatibility wrappers. New script-aware variants cover P2TR callers. Regression tests pin the one-vbyte differences: 251 vs 250 vB for redemption and 111 vs 110 vB for a fresh moved-funds sweep. They also cover two-input sweeps, fee caps, proposal wiring, and legacy compatibility. Verified with go test for pkg/tbtcpg under default and frost_native builds, plus go vet.
What
This is the Go node/coordinator side of the tBTC FROST/Schnorr migration. It teaches keep-core to run threshold-Schnorr (Taproot) wallets end to end: a real distributed FROST DKG with the full on-chain submit → challenge → approve result lifecycle, an interactive ROAST signing pipeline wired into the production signing loop, and Taproot-native funds movement (deposit sweep, redemption, moving funds) with the matching Bitcoin, SPV-maintainer, and proposal-generator plumbing. Moving tBTC wallets from tECDSA to FROST gives key-path-only P2TR wallet outputs and, via ROAST, signing that makes progress despite unresponsive or misbehaving signers while producing attributable blame evidence.
The cryptographic engine lives in the companion Rust signer (
libfrost_tbtc, #4005) and is linked over a cgo FFI bridge behind build tags; a default (untagged) build compiles to today's legacy tECDSA behavior with the FROST code inert. Signing is interactive-only: the node drives per-attempt commit → sign → aggregate rounds through the ROAST coordinator, and the FFI bridge requires signer ABI 2.0 exactly — the contract revision that removed the earlier transitional one-shot ("coarse") signing symbols — failing closed on any mismatch. Wallet key generation is a distributed FROST DKG (round 1, sealed round-2 transport, finalize, engine-persisted key packages); there is no trusted-dealer path.The node addresses FROST wallets by canonical
bytes32wallet ID (with legacy 20-byte pubkey-hash compatibility mapping), builds and signs BIP-341 key-path spends for wallet transactions, and coordinates with theFrostWalletRegistry/FrostDkgValidatorcontracts from the tbtc-v2 side (#971).Scope
ROAST coordination (
pkg/frost/roast— pure Go, no cgo)Interactive signing + distributed DKG over FFI (
pkg/frost/signing)libfrost_tbtc(build tagsfrost_native,frost_tbtc_signer): session open / round 1 / round 2 / aggregate, engine-derived attempt context, engine-backed round-2 share verification for blame.pkg/nettransport adapter, sealed round-2 packages, group-pubkey extraction, key-package persistence via the engine.frost_roast_retry): bundle registry, evidence stash, transition exchange/producer, readiness checks, metrics.Node & wallet integration (
pkg/tbtc)FrostWalletRegistryevents, runs the distributed DKG, signs the result (digest computation matchingFrostDkgValidator, fixture-pinned inpkg/frost/registry), and drives on-chain submit / challenge / approve.tbtc.frostSigningBackendresolves to it — no silent fallback to a backend that cannot sign FROST wallets.Ethereum chain bindings & config (
pkg/chain/ethereum,cmd,config)FrostWalletRegistryandFrostDkgValidator; chain layer for DKG events, group selection, result validity/digest, and submit/challenge/approve transactions.Bridgebindings coveringrevealTaprootDeposit/TaprootDepositRevealed;WalletProposalValidatorupdates.tbtc.frostSigningBackendplus FROST-only deployment flags (tbtc.disableLegacyECDSA,tbtc.disableLegacySortitionPoolMonitoring,tbtc.disableFrostSortitionPoolMonitoring) and contract-address flags for the two new contracts.Bitcoin & funds-movement plumbing (
pkg/bitcoin,pkg/maintainer/spv,pkg/tbtcpg)Ops, docs, CI
docs/development/.frost-cgo-integrationCI gate: buildslibfrost_tbtcfrom the exact ref pinned inci/frost-signer-pin.envand runs the cgo-tagged real-crypto tests with skips forbidden, so engine/bridge drift cannot silently degrade coverage. The pin is bumped in the same PR as any ABI change.pkg/tecdsaintopkg/protocol/retry.Validation
frost-cgo-integrationgate above running the real-crypto cgo suites against the pinned signer.84dff7d28), running against the companion Rust signer (feat(tbtc/signer): Rust FROST/ROAST signer (distributed DKG, interactive-only signing, FFI ABI 2.0) #4005) and the tbtc-v2 FROST contracts/SDK (Local keep network docs updates #971 line): Taproot deposit reveal → FROST key-path deposit sweep (3e87d011…) → mint → redemption (86c978c6…) → second distributed DKG → FROST→FROST moving funds (f3b951b0…), leaving the source wallet Closing and the target wallet holding the moved-funds sweep request.Companion PRs
libfrost_tbtc): the engine this branch links over cgo.ci/frost-signer-pin.envpins the exact signer commit the CI gate builds; the two move in lockstep on ABI changes.FrostWalletRegistry/FrostDkgValidator/FrostAllowlist,revealTaprootDeposit(P2TR deposits with wallet x-only internal key + refund tapleaf), BIP-341 key-path sighash fraud coverage, P2TR redeemer addresses, and SDK Taproot/testnet4 support. The chain bindings in this PR are generated from those contracts.Review notes
The diff is large (395 files, +86.4k/−4.3k, 551 commits) but layers cleanly; every layer landed through individually reviewed internal PRs. A suggested slicing:
docs/rfc/), then the readiness manifest and ROAST-retry rollout plan (docs/development/).pkg/frost/roast— self-contained pure-Go coordinator with its own tests; no cgo, no node dependencies.pkg/frost/signing— read by build flavor: default (inert stubs) →frost_native(engine bridge, distributed DKG, runner) →frost_tbtc_signer(tBTC signer engine) →frost_roast_retry(retry orchestration). File suffixes name the flavor.pkg/tbtc— node integration:frost_dkg_*(DKG lifecycle),signing_loop_*(ROAST dispatch/selection/transition), wallet/Taproot files, and the startup backend guard.pkg/chain/ethereum— mostly generated bindings (frost/gen/…, regeneratedBridge.go); hand-reviewfrost_dkg.go,ethereum.go, and the tests.pkg/bitcoin+pkg/maintainer/spv+pkg/tbtcpg— mechanical P2TR/Taproot plumbing with heavy table tests.Deletions are small and mostly the
pkg/tecdsa/retry→pkg/protocol/retrymove; existing legacy behavior is preserved, with shared funds-movement paths extended (not forked) to handle P2TR.Status
Kept in draft until the FROST/ROAST activation decision is explicit. This branch is maintained current with
mainso it can become a direct merge target once cross-repo activation (this PR + #4005 + #971) is approved.