Skip to content

feat(tbtc/node): FROST/ROAST Go node — distributed DKG, interactive signing loop, Taproot wallet lifecycle#3866

Draft
mswilkison wants to merge 567 commits into
mainfrom
feat/frost-schnorr-migration-scaffold
Draft

feat(tbtc/node): FROST/ROAST Go node — distributed DKG, interactive signing loop, Taproot wallet lifecycle#3866
mswilkison wants to merge 567 commits into
mainfrom
feat/frost-schnorr-migration-scaffold

Conversation

@mswilkison

@mswilkison mswilkison commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

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 bytes32 wallet ID (with legacy 20-byte pubkey-hash compatibility mapping), builds and signs BIP-341 key-path spends for wallet transactions, and coordinates with the FrostWalletRegistry / FrostDkgValidator contracts from the tbtc-v2 side (#971).

Scope

ROAST coordination (pkg/frost/roast — pure Go, no cgo)

  • Coordinator state machine with deterministic, seed-derived participant selection (seed bridge, shuffle corpus + seed-vector fixtures) and retry adapter into the existing retry loop.
  • Authenticated wire messages (protobuf: signing package, share submission, evidence) with membership pre-auth, sender binding, and pre-allocation byte caps on unmarshal.
  • Round-2 share collection and a candidate-culprit blame classifier; equivocation detection bound to message body hashes, including coordinator equivocation surfaced via coordinator-signed package proofs with instant exclusion.
  • Per-attempt evidence recording (local evidence snapshots, overflow parking) and multi-coordinator soak tests.

Interactive signing + distributed DKG over FFI (pkg/frost/signing)

  • cgo bridge to libfrost_tbtc (build tags frost_native, frost_tbtc_signer): session open / round 1 / round 2 / aggregate, engine-derived attempt context, engine-backed round-2 share verification for blame.
  • Fail-closed ABI gate: requires FFI contract major 2 exactly, minor ≥ 0; a lib still exporting the retired coarse-signing symbols (major 1) refuses to link.
  • Distributed FROST DKG orchestration: multi-node runner over a pkg/net transport adapter, sealed round-2 packages, group-pubkey extraction, key-package persistence via the engine.
  • ROAST retry orchestration (build tag frost_roast_retry): bundle registry, evidence stash, transition exchange/producer, readiness checks, metrics.
  • Real-cgo integration tests: multi-node DKG and signing e2e (including libp2p multi-process), dropout retry, invalid-share exclusion, coordinator equivocation, share-conflict blame, overflow parking.

Node & wallet integration (pkg/tbtc)

  • FROST DKG coordinator: watches FrostWalletRegistry events, runs the distributed DKG, signs the result (digest computation matching FrostDkgValidator, fixture-pinned in pkg/frost/registry), and drives on-chain submit / challenge / approve.
  • Production signing-loop wiring: ROAST dispatcher and transition controller, member-level participant selection, a member-keyed and wallet-scoped ROAST coordinator registry, member-keyed operator-signature verification, and stable hashed session identifiers for key-path signing sessions. Fail-closed on partial multi-seat orchestration.
  • Startup guard: a FROST-enabled node refuses to start unless a usable native FROST signer engine is linked and tbtc.frostSigningBackend resolves to it — no silent fallback to a backend that cannot sign FROST wallets.
  • Taproot wallets: x-only wallet keys, canonical wallet-ID derivation (with legacy compatibility helpers, fixture-pinned derivation vectors), P2TR wallet output scripts, Taproot-native deposit model (wallet x-only internal key + refund tapleaf, with and without extra data), and key-path transaction building/signing for sweeps, redemptions, and moving funds.

Ethereum chain bindings & config (pkg/chain/ethereum, cmd, config)

  • Generated bindings for FrostWalletRegistry and FrostDkgValidator; chain layer for DKG events, group selection, result validity/digest, and submit/challenge/approve transactions.
  • Regenerated Bridge bindings covering revealTaprootDeposit / TaprootDepositRevealed; WalletProposalValidator updates.
  • New config surface: tbtc.frostSigningBackend plus 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)

  • BIP-341 helpers (leaf hash, taproot tweak, output key, P2TR script-tree outputs) and Taproot-aware transaction builder with witness handling.
  • Electrum client gains script-based queries (transactions/UTXOs by public-key script, mempool included) — P2TR wallets are addressed by output script, not a 20-byte key hash.
  • SPV maintainer and proposal generator recognize P2TR wallet outputs and Taproot deposits across deposit sweep, redemptions, moving funds, and moved-funds sweep.

Ops, docs, CI

  • RFC-20 (Schnorr/FROST migration scaffold) and RFC-21 (ROAST coordinator, retry, and transition evidence); readiness manifest and staged ROAST-retry rollout doc under docs/development/.
  • frost-cgo-integration CI gate: builds libfrost_tbtc from the exact ref pinned in ci/frost-signer-pin.env and 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.
  • Signing-liveness and performance client-info metrics; Dockerfile/Makefile build support; the retry package generalized out of pkg/tecdsa into pkg/protocol/retry.

Validation

  • CI: the standard Go build/test workflow (default build keeps legacy tECDSA behavior; FROST code paths compile inert) plus the frost-cgo-integration gate above running the real-crypto cgo suites against the pinned signer.
  • In-diff test surface: real-cgo multi-node e2e for distributed DKG and interactive ROAST signing (including libp2p multi-process), dropout/retry, invalid-share exclusion, coordinator- and share-equivocation blame, overflow parking, multi-coordinator soak, and fixture-pinned determinism (coordinator seed vectors, shuffle corpus, DKG result digest, wallet-ID derivation vectors).
  • Live end-to-end: the full funds-movement lifecycle has been proven twice on Bitcoin testnet4 (local anvil Ethereum + real testnet4 transactions and SPV proofs accepted by the Bridge), most recently on 2026-07-10 at this branch's current head (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

  • threshold-network/keep-core feat(tbtc/signer): Rust FROST/ROAST signer (distributed DKG, interactive-only signing, FFI ABI 2.0) #4005 — the Rust FROST/ROAST signer (libfrost_tbtc): the engine this branch links over cgo. ci/frost-signer-pin.env pins the exact signer commit the CI gate builds; the two move in lockstep on ABI changes.
  • threshold-network/tbtc-v2 Local keep network docs updates #971 — the contracts + SDK side: 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:

  1. Design first: RFC-20 and RFC-21 (docs/rfc/), then the readiness manifest and ROAST-retry rollout plan (docs/development/).
  2. pkg/frost/roast — self-contained pure-Go coordinator with its own tests; no cgo, no node dependencies.
  3. 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.
  4. pkg/tbtc — node integration: frost_dkg_* (DKG lifecycle), signing_loop_* (ROAST dispatch/selection/transition), wallet/Taproot files, and the startup backend guard.
  5. pkg/chain/ethereum — mostly generated bindings (frost/gen/…, regenerated Bridge.go); hand-review frost_dkg.go, ethereum.go, and the tests.
  6. pkg/bitcoin + pkg/maintainer/spv + pkg/tbtcpg — mechanical P2TR/Taproot plumbing with heavy table tests.

Deletions are small and mostly the pkg/tecdsa/retrypkg/protocol/retry move; 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 main so it can become a direct merge target once cross-repo activation (this PR + #4005 + #971) is approved.

@mswilkison mswilkison changed the title Draft: Add Schnorr/FROST migration scaffold package and RFC Draft: Add Schnorr/FROST scaffold and tBTC runtime signing adapter slice Feb 20, 2026
mswilkison added a commit that referenced this pull request Feb 26, 2026
## 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
@mswilkison mswilkison changed the title Draft: Add Schnorr/FROST scaffold and tBTC runtime signing adapter slice Draft (Umbrella): keep-core FROST/ROAST migration scaffold tracker (not for direct merge) Mar 1, 2026
@mswilkison mswilkison changed the title Draft (Umbrella): keep-core FROST/ROAST migration scaffold tracker (not for direct merge) Draft: keep-core FROST/ROAST readiness branch May 17, 2026
@mswilkison

Copy link
Copy Markdown
Contributor Author

Readiness evidence update for the tBTC Schnorr FROST/ROAST migration stack, 2026-05-20.

From a clean worktree at PR head 37b2ce78348c4ab1c4a98eda8adcf99fa3d9aa1e, the focused integration-tag package lane passed:

go test -timeout 20m -tags 'integration frost_native frost_tbtc_signer' ./pkg/frost/... ./pkg/tbtc

Observed package coverage:

  • pkg/frost
  • pkg/frost/retry
  • pkg/frost/roast
  • pkg/frost/signing
  • pkg/tbtc (221.475s)

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:

  • full go test -tags=integration ./... or equivalent full-stack current integration evidence
  • client-integration-test
  • deployment/testnet lanes
  • funded production-like wallet/sign/deposit/redemption/fraud/rollback run
  • operator rehearsal and signoff
  • final maintainer/security/runtime/governance acceptance

The corresponding tBTC evidence docs were pushed in tlabs-xyz/tbtc#402.

@mswilkison mswilkison marked this pull request as ready for review May 22, 2026 20:07
@mswilkison mswilkison changed the title Draft: keep-core FROST/ROAST readiness branch FROST/ROAST readiness branch May 22, 2026
@mswilkison mswilkison marked this pull request as draft May 22, 2026 20:32
mswilkison added a commit that referenced this pull request May 22, 2026
… 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
mswilkison added a commit that referenced this pull request May 22, 2026
… 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
mswilkison added a commit that referenced this pull request May 22, 2026
…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.
mswilkison added a commit that referenced this pull request May 22, 2026
…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\`.
mswilkison added a commit that referenced this pull request May 23, 2026
…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).
mswilkison added a commit that referenced this pull request May 24, 2026
#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>
mswilkison added a commit that referenced this pull request May 25, 2026
## 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.
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 47669a21-61a7-4def-a7e9-10a707136216

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/frost-schnorr-migration-scaffold

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

mswilkison added a commit that referenced this pull request Jun 2, 2026
## 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`
mswilkison added a commit that referenced this pull request Jun 11, 2026
…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.
mswilkison and others added 6 commits July 10, 2026 08:30
…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
@mswilkison mswilkison changed the title FROST/ROAST readiness branch feat(tbtc/node): FROST/ROAST Go node — distributed DKG, interactive signing loop, Taproot wallet lifecycle Jul 10, 2026
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>
mswilkison and others added 12 commits July 11, 2026 15:55
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.
@mswilkison mswilkison marked this pull request as ready for review July 13, 2026 17:19
@mswilkison mswilkison marked this pull request as draft July 13, 2026 17:19
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant