Skip to content

FROST/ROAST readiness branch#3866

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

FROST/ROAST readiness branch#3866
mswilkison wants to merge 461 commits into
mainfrom
feat/frost-schnorr-migration-scaffold

Conversation

@mswilkison

@mswilkison mswilkison commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

Current State (as of 2026-05-17)

This draft PR is the umbrella readiness branch for feat/frost-schnorr-migration-scaffold.
It is being kept current with main so it can become a direct merge target if the FROST/ROAST stack is approved for activation.

It remains in draft until the remaining phase-gate, governance, and cross-repository readiness items are closed.

Canonical Status Sources

  • Cross-repo migration tracker: docs/frost-migration/external-repository-tracking.md (in tlabs-xyz/tbtc)
  • Companion tBTC umbrella draft: https://github.com/tlabs-xyz/tbtc/pull/10
  • Latest readiness audit: docs/reviews/frost-roast-production-readiness-2026-05-16.md (in tlabs-xyz/tbtc)

Latest Refresh

  • Merged current main into this branch.
  • Local verification passed for the FROST signing package and tBTC signer backend paths, with and without frost_native.
  • Local verification also passed the native TBTC signer-path tests covering the FFI signing primitive and signing executor.

Remaining Cross-Repo Closure Items

  • Wait for CI from the latest refresh to complete.
  • Capture the first post-fix funded nightly live run artifact for Phase 4.
  • Record final approver signoff in the Phase 4 decision/packet docs.
  • Execute external org archive/redirect mapping and record results.

Notes

  • Keep this PR in draft until the activation decision is explicit.
  • Treat it as the readiness branch for the integrated keep-core side of the stack, not only a historical index.

@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: 20bcb21e-7c0b-4df7-9a8f-13649244cf08

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 and others added 16 commits June 20, 2026 19:55
Fold of two Codex #4101 P2 findings:

- P2-1 (suppress outer fallbacks): the interactive-only guard lived only inside the
  FFI adapter, so when the native FFI path was unavailable (ErrNativeCryptographyUnavailable
  before the adapter's guard) the OUTER buildTaggedNativeExecutionBridge/Adapter still
  delegated to the legacy backend, because nativeExecutionFallbackAllowed() stayed true.
  Gate that single function on the flag: interactive-only now returns false there,
  closing every outer legacy/coarse fallback (the bridge + adapter consult it before
  delegating). New backend test asserts the suppression.

- P2-2 (terminal classification): the adapter's refusal returned a plain error, so the
  tBTC signingRetryLoop (which only aborts on ErrTerminalSigningFailure) treated this
  deterministic configuration failure as retryable and spun to timeout. Wrap the
  refusal with %w ErrTerminalSigningFailure; the adapter test now asserts errors.Is.

Also folds my own review's scope notes into the gate doc: interactive-only is
format-agnostic (refuses coarse for every signer format the native executor handles),
closes both the inner FFI primitive and the outer fallbacks, and fails all native
signing closed in a build without the interactive engine - so enable it only on a
frost_native node with the audit gate on.

Builds across all tag combos; full default + frost_native/frost_roast_retry suites
pass; gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…coarse flag

Fold of a third round of Codex #4101 P2 findings - the flag's fail-closed behavior
still had two holes, both now enforced at the backend Execute (the action) so they
cannot be bypassed by a caller:

- The DEFAULT backend fails OPEN. KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY was checked
  only in nativeExecutionFallbackAllowed + the native FFI adapter. A node left at the
  documented default (""/legacy) signs straight through legacyExecutionBackend.Execute
  (the tECDSA/coarse signer), never touching those guards - so the safety switch failed
  open under the default config. legacyExecutionBackend.Execute now refuses with a
  terminal error when the flag is on.

- Outer native refusals were retryable. When the native path is unavailable before the
  FFI adapter's terminal refusal can run (no FFI executor, or the bridge returns
  ErrNativeCryptographyUnavailable with the fallback suppressed), the bridge/adapter
  return a bare ErrNativeCryptographyUnavailable; the tBTC signingRetryLoop only aborts
  on ErrTerminalSigningFailure, so it retried this deterministic failure to timeout.
  nativeExecutionBackend.Execute now promotes that unavailable error to terminal when
  the flag is on (and leaves it untouched when off).

Tests: legacy terminal refusal; native unavailable->terminal promotion plus a flag-off
pass-through (no regression). Builds across all tag combos; full default +
frost_native/frost_roast_retry suites pass; gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ult off) (#4101)

## What

The **reversible, un-gated** first half of coarse-path retirement
(RFC-21 Phase 7.3). Adds a default-**off**
`KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY` gate; when set, the executor
**refuses** to fall through to the coarse signing primitive — if
interactive signing did not run (its audit gate off, or no engine
registered), the attempt fails **closed** rather than silently signing
over the retired coarse path.

Only the `(nil signature, nil error)` "interactive not enabled → coarse"
fall-through becomes a refusal; the hard-fail on a *committed*
interactive failure is unchanged.

## Safety / sequencing

- **Default off → production unchanged.** Coarse stays the path until an
operator flips this on.
- Flipping it on **is** the tECDSA→FROST cutover for that node (no
coarse fallback), so it stays off until the `frost-secp256k1-tr`
external audit clears and the recovery-leaf decision lands.
- The **irreversible** part — deleting the transitional coarse FFI
primitive + the deterministic-nonce path — is the deliberate follow-up,
not in this PR.

## Tests

- `TestEntry_InteractiveOnly_RefusesCoarseFallback` — orchestration
active + interactive audit gate off + this flag on → the executor
returns a refusal naming the env var, no signature.
- `TestEntry_InteractiveSigningOnlyEnabled_ParsesFlag` — flag parsing.
- Existing static-fallback executor tests unchanged (flag defaults off).
Builds clean across tag combos; vet + gofmt clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
… real engine

Item 1 of the pre-production handoff (design locked via Codex consult, shape A): the
union of the two half-faked e2es. roast_runner_bus_net_e2e proves runner+transport with
a FAKE engine; roast_real_cgo_interactive_e2e proves the REAL engine with no runner and
no transport. Neither exercises the real-engine <-> real-pkg/net-transport <-> runner
seam together. This does: n interactive signing runners, each over its own real pkg/net
BroadcastChannel bus, driving the real cgo engine to a real BIP-340 signature, in one
process (full-included 2-of-2 and t-of-included 3/threshold-2 subset).

Key wiring discovery (no engine change needed): the Go RFC-21 coordinator election seeds
on SHA256(dkgGroupPublicKey || sessionID || messageDigest), and the Rust engine seeds the
same shuffle on SHA256(keyGroup || sessionID || messageDigest) where keyGroup is the DKG
handle string. Passing []byte(keyGroup) as the binding's dkgGroupPublicKey makes the two
independent derivations agree, so the runner's engine-vs-binding coordinator cross-check
(never before exercised against the real engine) passes. RunDKG persists a resolvable key
group, so there is no loadInteractiveKeyGroup gap on this path.

Shape (A) shares ONE process-global engine across all seats (ENGINE_STATE is a
process-global OnceLock<Mutex>; the multi-seat fix #4098 is what lets one engine serve
every local member). The seam surfaced a real, correct boundary: the engine's aggregate
completion marker is per-ATTEMPT (attempt_id, message, root), not per-member, so the first
seat to aggregate produces the signature and co-resident seats observe
interactive_attempt_already_aggregated. That is faithful — in production each node has its
own engine — and every seat still drives its FULL transport (commitments + shares
broadcast/collected over real pkg/net) before aggregate. Assertion: exactly one seat
aggregates a real 64-byte signature and reaches Succeeded; every other seat reached
aggregate and hit the per-attempt idempotency marker (no other failure).

Skip-guarded (absent/stale libfrost_tbtc skips at the up-front DKG). Verified vs the
rebuilt lib: both tests pass, -race -count=3 clean (exactly one winner, no races), skip
without the lib, cgo vet + gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…crypto tests

Item 2 of the pre-production handoff (design locked via Codex consult). CI builds NONE
of the frost_native/frost_tbtc_signer/cgo tags, so the real-crypto interactive tests
(incl. the multi-node e2e in this PR) are validated locally only and can silently rot.

Adds .github/workflows/frost-cgo-integration.yml: it checks out the Go branch, checks
out a PINNED mirror commit (ci/frost-signer-pin.env - an explicit SHA, not the moving
tip, so an engine/bridge drift fails CI loudly in one reviewed unit instead of silently
turning the real-crypto tests into skips), builds libfrost_tbtc, verifies the exported
frost_tbtc_* ABI symbols, and runs the cgo-tagged tests with skips FORBIDDEN. The
require-cgo gate is KEEP_CORE_FROST_REQUIRE_CGO=true, which flips skipFrostUnavailable's
lib-unavailable SKIP to a FATAL - so an absent/stale/unloadable lib fails the job rather
than quietly dropping coverage. Linker note (Codex): -Wl,--no-as-needed retains the
DT_NEEDED on the lib even though the bridge references it only via dlsym(RTLD_DEFAULT).

The Go interactive code and the signer crate live on separate branches today, so the
gate checks the pinned mirror commit out into a side path and builds from there; after
the branches merge, swap the cross-branch checkout for an in-tree cargo build and keep
the gate.

Locally validated: require-mode FAILS without the lib (was a skip), SKIPS when unset,
PASSES with the lib; the workflow YAML parses (7 steps); the verified ABI symbols are
present in the built lib. A CI workflow can only be fully exercised by running it on a
PR. Production binary packaging + a structured ABI-version assertion are deliberately
left as follow-ups (release pipeline / mirror), noted in the handoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The interactive signing runner surfaces outcomes only via return values - no logs or
metrics - so before the coordinated tECDSA->FROST flip there is no operator-visible
signal for "is interactive signing working, and is anything failing closed?". Adds
process-wide cumulative counters following the roast_retry_metrics.go pattern:

  - frost_interactive_signing_success_total / _failure_total: whether the interactive
    path, once driven, produces signatures or fails on a committed attempt. Emitted at
    the single executor-entry chokepoint where the drive's (signature, error) outcome
    is interpreted (the inactive fall-through - both nil - counts as neither).
  - frost_interactive_signing_coarse_fallback_refused_total: the FLIP-SAFETY signal -
    increments whenever no-coarse-fallback mode (KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY)
    terminally refuses a would-be coarse/legacy signing, at all three #4101 refusal
    sites (legacy backend, native backend promotion, FFI adapter). During a staged flip
    a misconfigured node - interactive-only on but interactive signing not running -
    shows up here instead of silently failing every attempt.

RegisterInteractiveSigningMetrics mirrors RegisterRoastRetryMetrics; operators wire it
at startup (alongside the existing one, when the registration wiring lands). Emitted in
every build, stays at zero until the gated interactive path runs - inert by default.

This is the one genuinely buildable, non-gated item from the handoff's §6 production-
integration review: §6.1 (signer material) is mostly already built (DKG+persistence;
resolver fail-closed is tested), §6.2 activation wiring + §6.4 wallet lifecycle are
gated, and the rest of §6.3 is ops/runbooks.

Builds across all tag combos; new tests pass (incl. an end-to-end legacy-backend
refusal bumping the counter); no regression; cgo vet + gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… startup

Self-review fold (the one valid finding; Codex + Gemini both approved the PR with no
findings): the counters incremented internally but RegisterInteractiveSigningMetrics
was never called at startup - so they never reached the Prometheus scrape, leaving the
PR's "operator-visible signal" latent. (The pre-existing RegisterRoastRetryMetrics had
the same gap.)

Wire both Phase 7.3 metric families at the existing tbtc.Initialize clientInfo
registration site (right beside ObserveApplicationSource("tbtc", ...)). This is inert:
the sources report zero until the gated interactive path runs, and registering a scrape
callback does not activate any signing behavior - safe regardless of the cutover gates.
Wiring both (not only the new one) avoids leaving the sibling roast-retry counters
unexposed next to a freshly-exposed interactive family.

Builds clean (default + frost_native); vet + gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…off Items 1+2) (#4102)

Pre-production handoff **Items 1 and 2**, bundled because the CI gate's
whole purpose is to run the new e2e (and the gate's require-mode
enforcement lives in the e2e's helper file).

## Item 1 — real-crypto multi-node e2e

The union of the two half-faked e2es — `roast_runner_bus_net_e2e` (real
runner+transport, **fake** engine) and `roast_real_cgo_interactive_e2e`
(**real** engine, no runner/transport). Neither exercises the
**real-engine ⇄ real-`pkg/net`-transport ⇄ runner** seam together; this
does: n runners, each over its own real `pkg/net` bus, driving the real
cgo engine to a **real BIP-340 signature** in one process (full-included
2-of-2 and t-of-included 3/threshold-2 subset).

- **Wiring discovery (no engine change):** the Go RFC-21 election seeds
on `SHA256(dkgGroupPublicKey ‖ sessionID ‖ messageDigest)` and the Rust
engine on `SHA256(keyGroup ‖ …)`; passing `[]byte(keyGroup)` as the
binding's `dkgGroupPublicKey` makes them agree, so the runner's
engine-vs-binding coordinator cross-check — **never before run against
the real engine** — passes.
- **Faithful shared-engine boundary surfaced by the seam:** one
process-global engine ⇒ the per-attempt aggregate idempotency marker
lets exactly one seat aggregate the signature; co-resident seats observe
`interactive_attempt_already_aggregated` *after* completing their full
transport. In production each node has its own engine. Asserted
precisely (exactly one winner + Succeeded; all others reached aggregate
and hit the marker).

## Item 2 — pre-merge cgo CI gate

`.github/workflows/frost-cgo-integration.yml`: checks out the Go branch
+ a **pinned** mirror commit (`ci/frost-signer-pin.env`), builds
`libfrost_tbtc`, verifies exported `frost_tbtc_*` symbols, and runs the
cgo-tagged tests with **skips forbidden**
(`KEEP_CORE_FROST_REQUIRE_CGO=true` flips `skipFrostUnavailable`'s
lib-unavailable skip → fatal). An explicit SHA pin (not the moving tip)
makes an engine/bridge drift fail loudly in one reviewed unit instead of
silently dropping coverage. `-Wl,--no-as-needed` retains the `DT_NEEDED`
under the bridge's `dlsym(RTLD_DEFAULT)` model.

## Validation

- Item 1: against the rebuilt lib both tests pass, `-race -count=3`
clean (exactly one winner, no races), skip without the lib, cgo vet +
gofmt clean.
- Item 2: require-mode **fails** without the lib (was a skip), **skips**
when unset, **passes** with the lib; workflow YAML parses (7 steps); the
verified ABI symbols are present in the built lib.
- **A CI workflow can only be fully exercised by running it on a PR** —
the require-mode mechanism + YAML are locally validated; the job itself
runs here.

## Deliberately deferred (noted in the handoff)

- **Production binary packaging** (ship `.so` alongside `keep-client`,
rpath, fail-closed startup preflight) — release-pipeline / MacLane.
- **Structured ABI-version assertion** (the current `frost_tbtc_version`
string isn't an ABI contract) — mirror.

> CI does not otherwise build the cgo tags; without this gate the path
is inert in CI. The gate is the fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
#4103)

## What

The one genuinely **buildable, non-gated** item from the handoff's §6
production-integration review. The interactive signing runner surfaces
outcomes only via return values — no logs or metrics — so before the
coordinated tECDSA→FROST flip there's no operator-visible signal for
*"is interactive signing working, and is anything failing closed?"*

Adds process-wide cumulative counters following the existing
`roast_retry_metrics.go` pattern:

- **`frost_interactive_signing_success_total` / `_failure_total`** —
whether the interactive path, once driven, produces signatures or fails
on a committed attempt. Emitted at the **single executor-entry
chokepoint** where the drive's `(signature, error)` outcome is
interpreted (the inactive fall-through — both nil — counts as neither).
- **`frost_interactive_signing_coarse_fallback_refused_total`** — the
**flip-safety signal**: increments whenever no-coarse-fallback mode
(`KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY`) terminally refuses a
would-be coarse/legacy signing, at all three #4101 refusal sites (legacy
backend, native-backend promotion, FFI adapter). During a staged flip a
misconfigured node — interactive-only on but interactive signing not
running — shows up here instead of silently failing every attempt.

`RegisterInteractiveSigningMetrics` mirrors `RegisterRoastRetryMetrics`;
operators wire it at startup (alongside the existing one, when the
registration wiring lands). Emitted in every build, stays at zero until
the gated interactive path runs — **inert by default**.

## Why this is the only code item here

From the §6 review (verified against code, in
`FROST_PRODUCTION_HANDOFF.md`): §6.1 signer-material is **mostly already
built** (material flows via DKG + persistence; the resolver fail-closed
is already tested), §6.2 activation wiring and §6.4 wallet lifecycle are
**gated** (audit/recovery-leaf/MacLane), and the rest of §6.3 is
**ops/runbooks**. Observability is the one un-gated code slice — and
it's exactly what the coordinated flip needs.

## Validation

Builds across all tag combos; new tests pass (incl. an end-to-end
legacy-backend refusal bumping the counter); no regression in
backend/executor-entry suites; cgo vet + gofmt clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…t version

The Go-side companion to the mirror's frost_tbtc_abi_version export. The bridge and the
lib are linked at runtime via dlsym; a symbol that resolves but changed MEANING is
silent corruption. This adds a fail-closed preflight that asserts the linked lib's FFI
contract version before any contract-sensitive call.

- Pure, fully-unit-tested rule (native_tbtc_signer_abi_version.go, untagged): lib
  abi_major must EQUAL requiredTBTCSignerABIMajor (1) and abi_minor must be >=
  requiredTBTCSignerABIMinMinor (0). Default-build tests cover every branch (wrong major
  either direction, too-old minor, higher-minor-additive-ok) via a parameterized helper.
- cgo preflight (native_tbtc_signer_abi_version_frost_native.go): fetches the version via
  the new frost_tbtc_abi_version FFI, runs ONCE per process (sync.Once - the lib is
  process-global, not hot-swapped) before the first engine op. MISSING symbol keeps
  ErrNativeCryptographyUnavailable in the chain (explicit message; lib predates ABI
  versioning) so it SKIPS in dev and is FATAL under the require-cgo gate like any stale
  lib; PRESENT-but-wrong (malformed/wrong-major/too-old-minor) is ErrTBTCSignerABIIncompatible
  and fails loudly ALWAYS.
- Gate: callBuildTaggedTBTCSignerOperation (the single chokepoint every request-taking
  engine op funnels through) runs the preflight first. The no-arg version/abi-version
  calls bypass it, so no recursion.
- Bumps ci/frost-signer-pin.env to the mirror commit that adds frost_tbtc_abi_version
  (25e4f27), and adds that symbol to the CI gate's verified set, so the gate builds a
  lib whose ABI the preflight accepts.

Design via Codex consult (major.minor; exact major + min minor; init-time fail-closed;
explicit missing-symbol error; no upper bound; frozen {abi_major,abi_minor} surface).
Builds across tag combos; untagged rule tests + cgo preflight test pass; require-cgo full
run green against the ABI lib; no-lib skips; default suite unchanged; vet + gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex #4105 P2: the ABI preflight failed closed at the FFI op level, but the coarse
engine path (signWithTBTCSignerCoarseEngine) treats engine-op errors as ordinary
tbtc-signer failures and calls fallbackTBTCSignerLegacySigning - which, if the material
carries a legacy private key share, signs via legacy tECDSA anyway. So an incompatible
lib was NOT actually failing closed end to end: the ABI error got swallowed into a
legacy signature.

Fix at the single chokepoint - the top of signWithTBTCSignerCoarseEngine, before any
fallback: a guard that fails closed on ErrTBTCSignerABIIncompatible (a PRESENT-but-wrong
lib). A MISSING/absent lib is ErrNativeCryptographyUnavailable, NOT this sentinel, and
still falls back to legacy as the transitional design intends - only a responding-but-
incompatible lib is refused. The check is a build-split helper (tbtcSignerABIIncompatibility:
real cgo preflight when linked, no-op otherwise), indirected through a var
(tbtcSignerABIIncompatibilityCheck) so the no-fallback behavior is testable in the
frost_native build.

New test (mirrors _InvalidAttemptPolicy_DoesNotFallback): injects an incompatible ABI
verdict + a valid attempt + a legacy-key-share payload, asserts Sign returns
ErrTBTCSignerABIIncompatible, no signature, and ZERO fallback events. Bumps the signer
pin to the mirror tip (6e3718b, which also adds the header decl).

Builds across all tag combos; new test passes; existing fallback tests unchanged; cgo
vet + gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The P2-2 fail-closed test (a present-but-incompatible lib must not fall back to legacy
signing) is frost_native-tagged and uses the test seam, so it does not need an
actually-incompatible lib - but it was matched by neither standard CI (no frost_native
tags) nor the cgo gate's TestRealCgoInteractiveSigning -run filter, so its proof ran
only locally. This gate is the only CI that builds these tags; broaden -run to include
it so the no-fallback guarantee is enforced in CI alongside the real-crypto suite.

Verified locally: the broadened -run matches both groups and all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex #4105 re-review P2: Go's json.Unmarshal silently zero-fills a missing field, so a
present-but-partial frost_tbtc_abi_version response like {"abi_major":1} left abi_minor
at 0 - and since the required minimum minor is also 0, the compatibility check ACCEPTED
it. A broken/partial lib could thus bypass the fail-closed guard.

Extract the decode into a pure parseTBTCSignerABIVersion (untagged, unit-testable in the
default build) that uses pointer fields to distinguish "absent" from a legitimate zero
and rejects a payload missing abi_major and/or abi_minor as ErrTBTCSignerABIIncompatible.
Malformed JSON is rejected too; extra/unknown fields are still tolerated (an additive
minor bump may add fields old bridges ignore). The cgo assertTBTCSignerABICompatible now
calls it instead of a lenient inline Unmarshal.

Tests: parser accepts valid (incl. present zero minor) + extra fields; rejects missing
abi_minor, missing abi_major, empty object, malformed json, non-object. Builds across tag
combos; cgo preflight still passes against the real lib; gofmt + vet clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t version (#4105)

## What

The Go-side companion to the mirror's `frost_tbtc_abi_version` export
(#4104). The bridge and `libfrost_tbtc` are linked at runtime via
`dlsym`; a symbol that resolves but changed **meaning** is silent
corruption. This adds a **fail-closed preflight** that asserts the
linked lib's FFI contract version before any contract-sensitive call.

> **Depends on #4104** (the mirror PR adding `frost_tbtc_abi_version`).
This PR bumps `ci/frost-signer-pin.env` to that mirror commit
(`25e4f277a`), so the CI gate builds a lib whose ABI the preflight
accepts. Merge #4104 first (or together).

## Design (Codex-validated)

- **Rule** (`major.minor`): lib `abi_major` must **equal** the bridge's
required major (a higher major broke something newer; a lower major is
too old), and lib `abi_minor` must be **>=** the required minimum minor
(additive is backward-compatible; higher minor's extras are ignored).
Required = `1.0`.
- **Missing symbol** (old/absent lib) → keeps
`ErrNativeCryptographyUnavailable` in the chain (explicit message):
**skips** in dev, **fatal** under the require-cgo gate — same as any
stale lib.
- **Present-but-wrong** (malformed / wrong major / too-old minor) →
`ErrTBTCSignerABIIncompatible`, **fails loudly always**. A node must not
sign through a lib whose contract diverges.
- **Once-per-process** preflight (`sync.Once`; the lib is
process-global, not hot-swapped), gated at
`callBuildTaggedTBTCSignerOperation` — the single chokepoint every
request-taking engine op funnels through. The no-arg version calls
bypass it (no recursion).

## Tests

- Untagged unit tests cover **every branch** of the rule via a
parameterized helper (default build, no cgo needed).
- cgo preflight test asserts the linked lib is compatible (skips without
it).
- Validated: builds across tag combos; require-cgo full run green
against the ABI lib; no-lib skips; default suite unchanged; vet + gofmt
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…lution

# Conflicts:
#	pkg/chain/ethereum/ethereum.go
#	pkg/chain/ethereum/ethereum_integration_test.go
#	pkg/chain/ethereum/tbtc.go
mswilkison and others added 2 commits June 22, 2026 13:28
## Summary

Fixes a ROAST evidence-retention gap found during the Codex Security
scan of PR #3866.

`VerifyBundle` used to verify every bundled snapshot signature before
checking whether the bundle mutated this node's own previously submitted
snapshot. That meant a coordinator-signed bundle carrying a mutated self
snapshot returned `ErrSignatureInvalid` before
`own_snapshot_mutated_in_bundle` evidence could be emitted.

This PR moves the self-observation check immediately after the
coordinator bundle signature is verified and before the generic
per-snapshot signature loop. The bundle still fails closed, but the
submitted-vs-bundled evidence is retained first.

## Validation

- `go test ./pkg/frost/roast -run
TestVerifyBundle_RetainsMutatedSelfSnapshotEvidenceBeforeSignatureFailure
-count=1`
- `go test ./pkg/frost/roast -count=1`
- `git diff --check origin/feat/frost-schnorr-migration-scaffold...HEAD`

## Notes

The new regression test failed before the ordering fix with:

`expected ErrCensorshipDetected, got coordinator: bundle[0]: roast:
signature is invalid`

After the fix, it passes and verifies that
`own_snapshot_mutated_in_bundle` evidence is retained.
…uted DKG

Two separate-process (RFC-21 "shape B") real-crypto e2es over real libp2p, each
re-execing the test binary as N worker processes that each link libfrost_tbtc:

- shape-B (roast_shapeb_libp2p_multiproc_e2e): dealer DKG run once in a bootstrap
  subprocess, the encrypted key group copied into each worker's own state dir;
  every worker drives the ROAST interactive-signing runner over real libp2p and
  independently aggregates the same BIP-340 signature (n winners, vs the shared-
  engine in-process shape-A's one). Proves per-node engine/state isolation + the
  libp2p outer framing for the runner + ROAST + transport seam.

- distributed-DKG (roast_distributed_dkg_libp2p_multiproc_e2e): every worker runs
  the real distributed FROST DKG (part1/2/3) over libp2p, with round-2 per-recipient
  secret shares sealed via secp256k1 ECDH + AES-256-GCM (cleartext round-2 over a
  broadcast bus would let any node reconstruct a peer's share), so each node holds
  ONLY its own key package; then threshold-signs via the stateless low-level path.
  Closes the dealer-DKG custody gap end to end.

Both run green standalone and under the full -run TestRealCgoInteractiveSigning
cgo gate with KEEP_CORE_FROST_REQUIRE_CGO=true (skips forbidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uted DKG (#4110)

Stacks on the FROST/ROAST readiness branch (#3866). Adds two
separate-process ("shape B") real-crypto e2es over **real libp2p**,
complementing the in-process shape-A multinode test. Each re-execs the
test binary as N worker processes that each link `libfrost_tbtc`.

## What's added

- **shape-B** (`roast_shapeb_libp2p_multiproc_e2e_…`): dealer DKG run
once in a bootstrap subprocess, the encrypted key group copied into each
worker's own state dir; every worker drives the **ROAST
interactive-signing runner** over real libp2p and independently
aggregates the same BIP-340 signature (n winners, vs the shared-engine
shape-A's one). Covers per-node engine/state isolation + the libp2p
outer framing for the runner + ROAST + transport seam.
- **distributed-DKG** (`roast_distributed_dkg_libp2p_multiproc_e2e_…`):
every worker runs the **real distributed FROST DKG** (`part1/2/3`) over
libp2p, with round-2 per-recipient secret shares **sealed via secp256k1
ECDH + AES-256-GCM** (cleartext round-2 over a broadcast bus would let
any node sum `f_i(j)` and reconstruct a peer's share), so each node
holds **only its own key package**; then threshold-signs via the
stateless low-level path. Closes the dealer-DKG key-custody gap end to
end.

## Verification

Both green standalone (stable across repeated runs) and under the full
cgo gate, linking `libfrost_tbtc` built from the pinned signer ref
(`ci/frost-signer-pin.env` = `6e3718ba0`, unchanged):

```
CGO_ENABLED=1 KEEP_CORE_FROST_REQUIRE_CGO=true \
  go test -tags "frost_native frost_tbtc_signer" -run TestRealCgoInteractiveSigning ./pkg/frost/signing/
```

All 6 real-crypto tests pass together on this branch (4 shape-A +
shape-B + distributed-DKG).

## CI

The `frost-cgo-integration` gate already exercises both new tests — its
`-run 'TestRealCgoInteractiveSigning'` matches them by prefix, and the
pinned crate already exports the `dkg_part1/2/3` + low-level sign FFI
they use. No workflow change needed. (Heads-up: these are multi-process
+ gossipsub tests; robust locally via retransmission + warmup, but
mesh-convergence time varies — if shape-B ever flakes on a slow runner,
move it to a non-blocking job.)

## Note

Building the distributed-DKG test confirmed `dkg_part1/2/3` interoperate
over the transport — but the node's DKG path
(`executeTBTCSignerFROSTDKG`) still uses the dealer `RunDKGWithSeed`.
Wiring distributed DKG into the node remains the readiness-gate-blocking
work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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