diff --git a/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go b/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go
new file mode 100644
index 0000000000..b2caa531dd
--- /dev/null
+++ b/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go
@@ -0,0 +1,230 @@
+//go:build frost_native && frost_tbtc_signer && cgo
+
+package signing
+
+import (
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "testing"
+
+ "github.com/keep-network/keep-core/pkg/chain/local_v1"
+ "github.com/keep-network/keep-core/pkg/operator"
+)
+
+// This file is the REAL-cgo interactive signing end-to-end test: a full FROST DKG
+// that PERSISTS a key group, followed by an interactive ROAST signing round
+// driven through the engine's INTERACTIVE session API
+// (DeriveInteractiveAttemptContext -> InteractiveSessionOpen -> InteractiveRound1
+// -> NewSigningPackage -> InteractiveRound2 -> InteractiveAggregate) with real
+// frost-secp256k1-tr cryptography. It complements:
+// - TestBuildTaggedTBTCSignerInteractiveFROSTBridge_WithLinkedSigner, which
+// proves the real crypto over the LOW-LEVEL Sign/Aggregate API by feeding
+// KeyPackage bytes directly; and
+// - the fake-engine runner suite, which proves the Go-side orchestration
+// without crypto.
+// This is the missing combination: the INTERACTIVE session API the runner uses,
+// with real crypto, the prerequisite toward coarse-path retirement.
+//
+// The DKG -> interactive keyGroup glue is RunDKG: InteractiveSessionOpen resolves
+// the signing key by a keyGroup IDENTIFIER (engine-internal persisted material),
+// not KeyPackage bytes - so the low-level DKG (Part1/2/3) result, which the test
+// holds as bytes, is NOT loadable as an interactive keyGroup. RunDKG runs the full
+// DKG and PERSISTS the result under a returned keyGroup the interactive (and
+// coarse) signing path then resolves - the same flow production uses (the wallet
+// layer runs DKG, gets a keyGroup, then signs).
+//
+// Correctness is proven by the engine's SUCCESSFUL InteractiveAggregate: FROST
+// validates the signature shares and the aggregate internally, so a non-error
+// 64-byte BIP-340 signature is a valid threshold signature over the message under
+// the keyGroup's group key. An ADDITIONAL external schnorr.Verify is intentionally
+// NOT done here: the engine exposes no API to retrieve a keyGroup's group public
+// key (RunDKG returns only the keyGroup id, and ExtractDkgGroupPublicKeyFromMaterial
+// needs the persisted material bytes the test does not hold), so external
+// verification would need a new keyGroup->group-pubkey accessor. That is the one
+// remaining nicety; the e2e itself is complete via the internal validation.
+//
+// To run it, link the signer library so the frost_tbtc_* symbols resolve, e.g.:
+//
+// CGO_ENABLED=1 \
+// CGO_LDFLAGS="-L
-lfrost_tbtc -Wl,-rpath," \
+// go test -tags "frost_native frost_tbtc_signer" \
+// -run TestRealCgoInteractiveSigning_EndToEnd ./pkg/frost/signing/
+//
+// Every engine call is guarded by skipFrostUnavailable: when the lib is absent, or
+// present but stale (an older dylib missing a newer symbol such as
+// frost_tbtc_derive_interactive_attempt_context), the test SKIPS with a message
+// naming the operation, rather than failing - so it is inert in CI builds that do
+// not link the Rust signer and runs to completion only against a current lib.
+
+func TestRealCgoInteractiveSigning_EndToEnd(t *testing.T) {
+ t.Setenv("TBTC_SIGNER_PROFILE", "development")
+ t.Setenv("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false")
+ // RunDKG persists the DKG result in the signer's ENCRYPTED state, so a linked
+ // signer needs a state encryption key and an ISOLATED, fresh state path. Without
+ // them a clean linked environment fails before signing (missing key), and a
+ // shared/default state path makes reruns conflict on the fixed session id.
+ // t.TempDir() yields a fresh path per run, so each run starts clean.
+ stateKey := make([]byte, 32)
+ for i := range stateKey {
+ stateKey[i] = byte(i + 1)
+ }
+ t.Setenv("TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX", hex.EncodeToString(stateKey))
+ t.Setenv("TBTC_SIGNER_STATE_PATH", filepath.Join(t.TempDir(), "signer-state"))
+
+ engine := &buildTaggedTBTCSignerEngine{}
+
+ const threshold = 2
+ participantIDs := []byte{1, 2, 3}
+ signingMembers := []byte{1, 2}
+ message := bytesOf(0x42, 32)
+
+ // 1. Full DKG that persists a key group. RunDKG runs the whole DKG over the
+ // participants and returns a keyGroup the signing path resolves.
+ keyGroup := runRealCgoDKGKeyGroup(t, engine, participantIDs, threshold)
+
+ // 2. Interactive signing over the chosen t-subset, driven through the engine's
+ // interactive session API - the same calls the interactiveSigningRunner makes,
+ // here with real crypto against the DKG-persisted keyGroup.
+ derived, err := engine.DeriveInteractiveAttemptContext(
+ "real-cgo-session-1",
+ message,
+ keyGroup,
+ threshold,
+ 0, // 0-based attempt number; the bridge converts to the 1-based wire value
+ uint16sOf(signingMembers),
+ )
+ skipFrostUnavailable(t, "derive interactive attempt context", err)
+ frostIDByMember := map[byte]string{}
+ for _, id := range derived.FrostIdentifiers {
+ frostIDByMember[byte(id.ParticipantIdentifier)] = id.FrostIdentifier
+ }
+
+ attemptIDByMember := make(map[byte]string, len(signingMembers))
+ commitments := make([]nativeFROSTCommitment, 0, len(signingMembers))
+ for _, member := range signingMembers {
+ open, err := engine.InteractiveSessionOpen(
+ "real-cgo-session-1",
+ uint16(member),
+ message,
+ keyGroup,
+ threshold,
+ nil, // key-path spend
+ derived.AttemptContext,
+ )
+ skipFrostUnavailable(t, fmt.Sprintf("interactive session open (member %d)", member), err)
+ attemptIDByMember[member] = open.AttemptID
+
+ commitmentData, err := engine.InteractiveRound1(
+ "real-cgo-session-1", open.AttemptID, uint16(member),
+ )
+ skipFrostUnavailable(t, fmt.Sprintf("interactive round 1 (member %d)", member), err)
+ commitments = append(commitments, nativeFROSTCommitment{
+ Identifier: frostIDByMember[member],
+ Data: commitmentData,
+ })
+ }
+
+ signingPackage, err := engine.NewSigningPackage(message, commitments)
+ skipFrostUnavailable(t, "new signing package", err)
+
+ signatureShares := make([]nativeFROSTSignatureShare, 0, len(signingMembers))
+ for _, member := range signingMembers {
+ shareData, err := engine.InteractiveRound2(
+ "real-cgo-session-1",
+ attemptIDByMember[member],
+ uint16(member),
+ signingPackage,
+ )
+ skipFrostUnavailable(t, fmt.Sprintf("interactive round 2 (member %d)", member), err)
+ signatureShares = append(signatureShares, nativeFROSTSignatureShare{
+ Identifier: frostIDByMember[member],
+ Data: shareData,
+ })
+ }
+
+ // A failed aggregate (other than an unavailable symbol) IS the verification:
+ // real FROST rejects invalid shares or a key/package mismatch here, so a
+ // non-error result is the proof the interactive round produced a valid
+ // threshold signature.
+ signatureBytes, err := engine.InteractiveAggregate(
+ "real-cgo-session-1",
+ attemptIDByMember[signingMembers[0]],
+ signingPackage,
+ signatureShares,
+ nil,
+ )
+ skipFrostUnavailable(t, "interactive aggregate", err)
+
+ // 3. The successful aggregate is a valid 64-byte BIP-340 signature (the engine
+ // validated the shares and the aggregate internally). External schnorr.Verify
+ // is omitted only for want of a keyGroup->group-pubkey accessor (see the file
+ // comment); assert well-formedness.
+ if len(signatureBytes) != 64 {
+ t.Fatalf("unexpected interactive signature length: %d", len(signatureBytes))
+ }
+}
+
+// skipFrostUnavailable turns an engine-call error into the right outcome: a missing
+// FFI symbol (lib absent, or present but stale and missing a newer symbol) SKIPS
+// the test naming the operation, while any other error is a real failure. nil is a
+// no-op. Centralizing this makes every step of the e2e robust to an incomplete lib
+// rather than only the first (RunDKG) call.
+func skipFrostUnavailable(t *testing.T, op string, err error) {
+ t.Helper()
+ if err == nil {
+ return
+ }
+ if errors.Is(err, ErrNativeCryptographyUnavailable) {
+ t.Skipf(
+ "linked tbtc-signer FFI symbol for %s unavailable (lib absent or stale; "+
+ "rebuild libfrost_tbtc): %v",
+ op, err,
+ )
+ }
+ t.Fatalf("%s: %v", op, err)
+}
+
+// runRealCgoDKGKeyGroup runs a full real FROST DKG over the participants via
+// RunDKG, which persists the result and returns the keyGroup the signing path
+// resolves. It skips the test if the linked tbtc-signer FFI symbols are absent.
+// Each participant carries a freshly generated operator public key (the DKG's
+// per-participant identifying key), so the request is well-formed.
+func runRealCgoDKGKeyGroup(
+ t *testing.T,
+ engine *buildTaggedTBTCSignerEngine,
+ participantIDs []byte,
+ threshold uint16,
+) string {
+ t.Helper()
+
+ participants := make([]NativeTBTCSignerDKGParticipant, 0, len(participantIDs))
+ for _, id := range participantIDs {
+ _, publicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve)
+ if err != nil {
+ t.Fatalf("operator key (participant %d): %v", id, err)
+ }
+ participants = append(participants, NativeTBTCSignerDKGParticipant{
+ Identifier: uint16(id),
+ PublicKeyHex: hex.EncodeToString(operator.MarshalUncompressed(publicKey)),
+ })
+ }
+
+ result, err := engine.RunDKG("real-cgo-dkg-session-1", participants, threshold)
+ skipFrostUnavailable(t, "run DKG", err)
+ if result.KeyGroup == "" {
+ t.Fatal("RunDKG returned an empty key group")
+ }
+ return result.KeyGroup
+}
+
+// uint16sOf widens member ids to the uint16 participant list the engine's
+// DeriveInteractiveAttemptContext expects.
+func uint16sOf(members []byte) []uint16 {
+ out := make([]uint16, 0, len(members))
+ for _, member := range members {
+ out = append(out, uint16(member))
+ }
+ return out
+}