Repository: celestiaorg/celestia-app

Issue:
Add SP1Groth16Verifier to zk ism types
## Description

Generalise the proof verification of sp1 groth16 proofs into a self-contained go type.
The verifier should be able to support any sp1 groth16 program, given its verifier key commitment and public inputs as raw bytes.

The code for this already exists and should be lifted into a new type for reusability. 

Discussion:

Solution patch:
diff --git a/x/zkism/internal/groth16/groth16.go b/x/zkism/internal/groth16/groth16.go
index 8fca7c57..a296bdbe 100644
--- a/x/zkism/internal/groth16/groth16.go
+++ b/x/zkism/internal/groth16/groth16.go
@@ -15,6 +15,9 @@ import (
 	"github.com/consensys/gnark/backend/witness"
 )
 
+// VerifyingKey is a simple type alias for the underlying gnark groth16 VerifyingKey.
+type VerifyingKey = groth16.VerifyingKey
+
 // NewVerifyingKey deserializes a Groth16 verifying key for the BN254 curve from a byte slice.
 //
 // It initializes a new verifying key instance for the BN254 scalar field and reads the
diff --git a/x/zkism/keeper/msg_server.go b/x/zkism/keeper/msg_server.go
index 34aecd8d..8ad8a899 100644
--- a/x/zkism/keeper/msg_server.go
+++ b/x/zkism/keeper/msg_server.go
@@ -81,7 +81,12 @@ func (m msgServer) UpdateZKExecutionISM(ctx context.Context, msg *types.MsgUpdat
 		return nil, err
 	}
 
-	if err := types.VerifyGroth16(ctx, ism.Groth16Vkey, ism.StateTransitionVkey, msg.Proof, msg.PublicValues); err != nil {
+	verifier, err := types.NewSP1Groth16Verifier(ism.Groth16Vkey)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := verifier.VerifyProof(msg.Proof, ism.StateTransitionVkey, msg.PublicValues); err != nil {
 		return nil, err
 	}
 
@@ -114,7 +119,12 @@ func (m msgServer) SubmitMessages(ctx context.Context, msg *types.MsgSubmitMessa
 		return nil, fmt.Errorf("invalid state root: expected %x, got %x", ism.StateRoot, publicValues.StateRoot)
 	}
 
-	if err := types.VerifyGroth16(ctx, ism.Groth16Vkey, ism.StateMembershipVkey, msg.Proof, msg.PublicValues); err != nil {
+	verifier, err := types.NewSP1Groth16Verifier(ism.Groth16Vkey)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := verifier.VerifyProof(msg.Proof, ism.StateMembershipVkey, msg.PublicValues); err != nil {
 		return nil, err
 	}
 
diff --git a/x/zkism/types/ism.go b/x/zkism/types/ism.go
index 5f438dc3..62b29f0c 100644
--- a/x/zkism/types/ism.go
+++ b/x/zkism/types/ism.go
@@ -34,6 +34,7 @@ func (ism *ZKExecutionISM) ModuleType() uint8 {
 }
 
 // Verify implements types.HyperlaneInterchainSecurityModule.
+// TODO: follow up PR, refactor/remove this code from here
 func (ism *ZKExecutionISM) Verify(ctx context.Context, metadata []byte, message util.HyperlaneMessage) (bool, error) {
 	zkProofMetadata, err := NewZkExecutionISMMetadata(metadata)
 	if err != nil {
diff --git a/x/zkism/types/verifier.go b/x/zkism/types/verifier.go
new file mode 100644
index 00000000..8fe835fb
--- /dev/null
+++ b/x/zkism/types/verifier.go
@@ -0,0 +1,88 @@
+package types
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"fmt"
+	"math/big"
+
+	"github.com/celestiaorg/celestia-app/v6/x/zkism/internal/groth16"
+)
+
+const (
+	// PrefixLen is the number of bytes taken from the SHA-256 hash
+	// of the verifying key to prefix Groth16 proofs.
+	PrefixLen = 4
+
+	// ProofSize is the expected size in bytes of the Groth16 proof itself,
+	// excluding the prefix.
+	ProofSize = 256
+)
+
+// SP1Groth16Verifier encapsulates the state required to verify Groth16 proofs
+// under the SP1 scheme. It stores a verifying key and its hash prefix, which
+// are used to check proof integrity and correctness.
+type SP1Groth16Verifier struct {
+	prefix [PrefixLen]byte
+	vk     groth16.VerifyingKey
+}
+
+// NewSP1Groth16Verifier constructs a new SP1Groth16Verifier from the provided
+// verifying key bytes. It initializes the internal verifying key and computes
+// the hash prefix used to validate proofs.
+//
+// Returns an error if the verifying key cannot be parsed.
+func NewSP1Groth16Verifier(vkBytes []byte) (*SP1Groth16Verifier, error) {
+	vk, err := groth16.NewVerifyingKey(vkBytes)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create new verifying key: %w", err)
+	}
+
+	vkHash := sha256.Sum256(vkBytes)
+	var prefix [PrefixLen]byte
+	copy(prefix[:], vkHash[:PrefixLen])
+
+	return &SP1Groth16Verifier{
+		prefix: prefix,
+		vk:     vk,
+	}, nil
+}
+
+// Prefix returns the verifier's SP1 hash prefix.
+func (v *SP1Groth16Verifier) Prefix() []byte {
+	return v.prefix[:]
+}
+
+// VerifyProof checks that the given proof is valid using the verifier's key,
+// the provided program verifying key commitment, and the public values.
+// The proof must be prefixed with the verifier key hash prefix.
+// Returns nil if the proof is valid, or an error otherwise.
+func (v *SP1Groth16Verifier) VerifyProof(proofBz, programVk, publicValues []byte) error {
+	if len(proofBz) != (PrefixLen + ProofSize) {
+		return fmt.Errorf("invalid proof length: expected %d, got %d", (PrefixLen + ProofSize), len(proofBz))
+	}
+
+	if !bytes.Equal(v.Prefix(), proofBz[:PrefixLen]) {
+		return fmt.Errorf("invalid proof prefix expected %x, got %x", v.prefix[:], proofBz[:PrefixLen])
+	}
+
+	proof, err := groth16.UnmarshalProof(proofBz[PrefixLen:])
+	if err != nil {
+		return fmt.Errorf("failed to unmarshal proof: %w", err)
+	}
+
+	vkCommitment := new(big.Int).SetBytes(programVk)
+	vkElement := groth16.NewBN254FrElement(vkCommitment)
+	inputsElement := groth16.NewBN254FrElement(groth16.HashBN254(publicValues))
+
+	pubWitness, err := groth16.NewPublicWitness(vkElement, inputsElement)
+	if err != nil {
+		return err
+	}
+
+	if err := groth16.VerifyProof(proof, v.vk, pubWitness); err != nil {
+		return fmt.Errorf("failed to verify proof: %w", err)
+	}
+
+	return nil
+}



Test patch:
diff --git a/x/zkism/types/verifier_test.go b/x/zkism/types/verifier_test.go
new file mode 100644
index 00000000..644fccfb
--- /dev/null
+++ b/x/zkism/types/verifier_test.go
@@ -0,0 +1,151 @@
+package types_test
+
+import (
+	"encoding/hex"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/celestiaorg/celestia-app/v6/x/zkism/types"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewGroth16Verifier(t *testing.T) {
+	groth16Vk := readGroth16Vkey(t)
+
+	tests := []struct {
+		name     string
+		vk       []byte
+		expError bool
+	}{
+		{
+			name:     "valid verifier key",
+			vk:       groth16Vk,
+			expError: false,
+		},
+		{
+			name:     "empty verifier key",
+			vk:       []byte{},
+			expError: true,
+		},
+		{
+			name:     "truncated verifier key",
+			vk:       groth16Vk[:16],
+			expError: true,
+		},
+		{
+			name:     "random garbage",
+			vk:       []byte{0x01, 0x02, 0x03, 0x04},
+			expError: true,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			verifier, err := types.NewSP1Groth16Verifier(tc.vk)
+
+			if tc.expError {
+				require.Error(t, err, "expected error but got none")
+				require.Nil(t, verifier)
+			} else {
+				require.NoError(t, err, "unexpected error constructing verifier")
+				require.NotNil(t, verifier)
+				require.NotEmpty(t, verifier)
+			}
+		})
+	}
+}
+
+func TestVerifyProof(t *testing.T) {
+	groth16Vk := readGroth16Vkey(t)
+	proofBz, valuesBz := readProofAndValues(t)
+
+	programVkHex := "0x00acd6f9c9d0074611353a1e0c94751d3c49beef64ebc3ee82f0ddeadaf242ef"
+	programVk, err := hex.DecodeString(strings.TrimPrefix(programVkHex, "0x"))
+	require.NoError(t, err)
+
+	verifier, err := types.NewSP1Groth16Verifier(groth16Vk)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name      string
+		proofBz   []byte
+		programVk []byte
+		valuesBz  []byte
+		expError  bool
+	}{
+		{
+			name:      "valid proof",
+			proofBz:   proofBz,
+			programVk: programVk,
+			valuesBz:  valuesBz,
+			expError:  false,
+		},
+		{
+			name:      "invalid proof length",
+			proofBz:   proofBz[:10],
+			programVk: programVk,
+			valuesBz:  valuesBz,
+			expError:  true,
+		},
+		{
+			name: "invalid prefix",
+			proofBz: func() []byte {
+				corrupt := make([]byte, len(proofBz))
+				copy(corrupt, proofBz)
+
+				corrupt[0] ^= 0xFF // flip first byte of prefix
+				return corrupt
+			}(),
+			programVk: programVk,
+			valuesBz:  valuesBz,
+			expError:  true,
+		},
+		{
+			name:      "nil program key",
+			proofBz:   proofBz,
+			programVk: nil,
+			valuesBz:  valuesBz,
+			expError:  true,
+		},
+		{
+			name:      "corrupted values",
+			proofBz:   proofBz,
+			programVk: programVk,
+			valuesBz:  []byte{0x01, 0x02, 0x03, 0x04},
+			expError:  true,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			err := verifier.VerifyProof(tc.proofBz, tc.programVk, tc.valuesBz)
+			if tc.expError {
+				require.Error(t, err, "expected error but got none")
+			} else {
+				require.NoError(t, err, "expected no error but got one")
+			}
+		})
+	}
+}
+
+func readGroth16Vkey(t *testing.T) []byte {
+	t.Helper()
+
+	groth16Vkey, err := os.ReadFile("../internal/testdata/groth16_vk.bin")
+	require.NoError(t, err, "failed to read verifier key file")
+
+	return groth16Vkey
+}
+
+func readProofAndValues(t *testing.T) ([]byte, []byte) {
+	t.Helper()
+
+	proofBz, err := os.ReadFile("../internal/testdata/state_transition/proof.bin")
+	require.NoError(t, err, "failed to read proof file")
+
+	inputsBz, err := os.ReadFile("../internal/testdata/state_transition/public_values.bin")
+	require.NoError(t, err, "failed to read proof file")
+
+	return proofBz, inputsBz
+}