Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions SIP-EIP-2537-BLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# SIP: Add EIP-2537 BLS12-381 Precompiles

## Abstract
This proposal introduces seven precompiled contracts to the Sei EVM for BLS12-381 curve operations, matching the Ethereum Pectra upgrade (EIP-2537). This enables Sei smart contracts to natively verify Ethereum Beacon Chain validator signatures, unlocking trustless light-client bridges between Sei and Ethereum.

## Motivation
Ethereum's Consensus Layer (Beacon Chain) uses BLS12-381 for validator signatures. Without native curve support, verifying these signatures on-chain is prohibitively expensive. Adding EIP-2537 precompiles to Sei enables:

- **Trustless Ethereum Bridge**: Sei contracts can directly verify Beacon Chain validator BLS signatures and sync committee attestations, enabling a fully trustless light-client bridge without relying on multisigs or oracle committees.
- **ZK Proof Verification**: Supports ZK-SNARKs (Groth16/PLONK) over the BLS12-381 curve, enabling privacy-preserving DeFi protocols and cross-chain state proofs.
- **Signature Aggregation**: Batch-verify thousands of BLS signatures in a single on-chain operation, reducing gas costs for multi-party protocols.
- **Pectra Compatibility**: Aligns Sei's EVM with Ethereum's Pectra upgrade, ensuring cross-chain tooling and contracts work seamlessly on both chains.

## Specification
The implementation strictly follows the [EIP-2537 specification](https://eips.ethereum.org/EIPS/eip-2537).

Seven precompiles at addresses `0x0b` through `0x11`:

| Address | Operation | Input | Output | Gas |
| :--- | :--- | :--- | :--- | :--- |
| `0x0b` | `BLS12_G1ADD` | 256 bytes (2 G1 points) | 128 bytes | 375 |
| `0x0c` | `BLS12_G1MSM` | 160*k bytes (k point-scalar pairs) | 128 bytes | variable |
| `0x0d` | `BLS12_G2ADD` | 512 bytes (2 G2 points) | 256 bytes | 600 |
| `0x0e` | `BLS12_G2MSM` | 288*k bytes (k point-scalar pairs) | 256 bytes | variable |
| `0x0f` | `BLS12_PAIRING_CHECK` | 384*k bytes (k G1-G2 pairs) | 32 bytes | 32600*k + 37700 |
| `0x10` | `BLS12_MAP_FP_TO_G1` | 64 bytes (field element) | 128 bytes | 5500 |
| `0x11` | `BLS12_MAP_FP2_TO_G2` | 128 bytes (Fp2 element) | 256 bytes | 23800 |

Key details:
- Points use **uncompressed encoding** (128 bytes for G1, 256 bytes for G2) in big-endian.
- G1MSM/G2MSM handle both single scalar multiplication (k=1) and multi-scalar multiplication with a discount table per EIP-2537.
- All precompiles accept **raw calldata** (no ABI encoding), matching Ethereum's precompile calling convention.
- Input validation includes field modulus range checks, on-curve checks, and subgroup checks where required.

## Rationale
The implementation wraps go-ethereum's native EIP-2537 precompiles (using the audited `gnark-crypto` library), ensuring byte-for-byte compatibility with Ethereum's Pectra execution spec tests. This avoids reimplementing complex curve arithmetic and inherits go-ethereum's security guarantees.

## Backwards Compatibility
These are new precompiles at addresses `0x0b`-`0x11`, which were previously unused in Sei's EVM. Existing Sei precompiles occupy the `0x1001`+ address range and are unaffected.

## Security Considerations
- The underlying `gnark-crypto` BLS12-381 implementation is formally verified and used in production by multiple Ethereum execution clients.
- Gas costs follow EIP-2537's pricing model with MSM discount tables, preventing DoS via expensive operations.
- All inputs are validated: field elements must be less than the BLS12-381 modulus, points must be on-curve, and subgroup membership is enforced for MSM and pairing operations.
55 changes: 55 additions & 0 deletions contracts/BLSCheck.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @title BLSCheck - EIP-2537 BLS12-381 precompile verification contract
/// @notice Calls BLS precompiles using raw staticcall per EIP-2537 spec
contract BLSCheck {
// EIP-2537 precompile addresses
address constant G1ADD = address(0x0b);
address constant G1MSM = address(0x0c);
address constant G2ADD = address(0x0d);
address constant G2MSM = address(0x0e);
address constant PAIRING = address(0x0f);
address constant MAP_FP_TO_G1 = address(0x10);
address constant MAP_FP2_TO_G2 = address(0x11);

/// @notice Tests G1 point addition with identity points (point at infinity)
/// @return true if precompile executes correctly
function checkG1Add() external view returns (bool) {
// Two G1 identity points (128 bytes each, all zeros) = 256 bytes
bytes memory input = new bytes(256);
(bool success, bytes memory result) = G1ADD.staticcall(input);
return success && result.length == 128;
}

/// @notice Tests G1 scalar multiplication via MSM (k=1) with identity point
/// @return true if precompile executes correctly
function checkG1Mul() external view returns (bool) {
// G1 identity point (128 bytes) + scalar (32 bytes) = 160 bytes
bytes memory input = new bytes(160);
(bool success, bytes memory result) = G1MSM.staticcall(input);
return success && result.length == 128;
}

/// @notice Tests G2 point addition with identity points
/// @return true if precompile executes correctly
function checkG2Add() external view returns (bool) {
// Two G2 identity points (256 bytes each) = 512 bytes
bytes memory input = new bytes(512);
(bool success, bytes memory result) = G2ADD.staticcall(input);
return success && result.length == 256;
}

/// @notice Tests pairing check with identity pair
/// @return true if precompile executes correctly and returns true (0x01)
function checkPairing() external view returns (bool) {
// G1 identity (128 bytes) + G2 identity (256 bytes) = 384 bytes
bytes memory input = new bytes(384);
(bool success, bytes memory result) = PAIRING.staticcall(input);
if (!success || result.length != 32) {
return false;
}
// Pairing with identity points should return true (last byte = 0x01)
return uint8(result[31]) == 1;
}
}
1 change: 1 addition & 0 deletions precompiles/bls/abi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
76 changes: 76 additions & 0 deletions precompiles/bls/bls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package bls

import (
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/sei-protocol/sei-chain/precompiles/utils"
)

// EIP-2537 precompile addresses (7 operations at 0x0b-0x11)
const (
G1AddAddress = "0x000000000000000000000000000000000000000b"
G1MSMAddress = "0x000000000000000000000000000000000000000c"
G2AddAddress = "0x000000000000000000000000000000000000000d"
G2MSMAddress = "0x000000000000000000000000000000000000000e"
PairingAddress = "0x000000000000000000000000000000000000000f"
MapG1Address = "0x0000000000000000000000000000000000000010"
MapG2Address = "0x0000000000000000000000000000000000000011"
)

// OpInfo stores metadata for an EIP-2537 BLS precompile operation.
type OpInfo struct {
Addr string
Name string
Impl vm.PrecompiledContract
}

// AllOps returns all 7 EIP-2537 BLS12-381 precompile operations.
func AllOps() []OpInfo {
return []OpInfo{
{G1AddAddress, "blsG1Add", &vm.Bls12381G1Add{}},
{G1MSMAddress, "blsG1MSM", &vm.Bls12381G1MultiExp{}},
{G2AddAddress, "blsG2Add", &vm.Bls12381G2Add{}},
{G2MSMAddress, "blsG2MSM", &vm.Bls12381G2MultiExp{}},
{PairingAddress, "blsPairing", &vm.Bls12381Pairing{}},
{MapG1Address, "blsMapG1", &vm.Bls12381MapG1{}},
{MapG2Address, "blsMapG2", &vm.Bls12381MapG2{}},
}
}

// BLSPrecompile wraps a native go-ethereum EIP-2537 precompile to satisfy
// Sei's IPrecompile interface. It handles raw calldata (no ABI encoding)
// per the EIP-2537 specification.
type BLSPrecompile struct {
vm.PrecompiledContract
address common.Address
name string
}

// NewPrecompile creates a BLS precompile wrapper for the given operation.
func NewPrecompile(op OpInfo) *BLSPrecompile {
return &BLSPrecompile{
PrecompiledContract: op.Impl,
address: common.HexToAddress(op.Addr),
name: op.Name,
}
}

// GetVersioned returns versioned precompiles for a specific BLS operation.
func GetVersioned(op OpInfo) utils.VersionedPrecompiles {
return utils.VersionedPrecompiles{
"1": NewPrecompile(op),
}
}

func (p *BLSPrecompile) GetABI() abi.ABI {
return abi.ABI{}
}

func (p *BLSPrecompile) GetName() string {
return p.name
}

func (p *BLSPrecompile) Address() common.Address {
return p.address
}
182 changes: 182 additions & 0 deletions precompiles/bls/bls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package bls

import (
"math/big"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)

func TestG1Add_IdentityPoints(t *testing.T) {
p := NewPrecompile(AllOps()[0]) // G1ADD

// Two G1 identity points (128 bytes each, all zeros) -> identity
input := make([]byte, 256)
result, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.NoError(t, err)
require.Equal(t, 128, len(result))
require.Equal(t, make([]byte, 128), result)
}

func TestG1Add_InvalidLength(t *testing.T) {
p := NewPrecompile(AllOps()[0])

// Invalid input length (not 256 bytes)
input := make([]byte, 100)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestG1MSM_IdentityPoint(t *testing.T) {
p := NewPrecompile(AllOps()[1]) // G1MSM

// G1 identity point (128 bytes) + scalar (32 bytes) = 160 bytes
input := make([]byte, 160)
result, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.NoError(t, err)
require.Equal(t, 128, len(result))
require.Equal(t, make([]byte, 128), result)
}

func TestG1MSM_InvalidLength(t *testing.T) {
p := NewPrecompile(AllOps()[1])

// Not a multiple of 160 bytes
input := make([]byte, 100)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestG2Add_IdentityPoints(t *testing.T) {
p := NewPrecompile(AllOps()[2]) // G2ADD

// Two G2 identity points (256 bytes each) -> identity
input := make([]byte, 512)
result, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.NoError(t, err)
require.Equal(t, 256, len(result))
require.Equal(t, make([]byte, 256), result)
}

func TestG2Add_InvalidLength(t *testing.T) {
p := NewPrecompile(AllOps()[2])

input := make([]byte, 100)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestG2MSM_IdentityPoint(t *testing.T) {
p := NewPrecompile(AllOps()[3]) // G2MSM

// G2 identity point (256 bytes) + scalar (32 bytes) = 288 bytes
input := make([]byte, 288)
result, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.NoError(t, err)
require.Equal(t, 256, len(result))
require.Equal(t, make([]byte, 256), result)
}

func TestG2MSM_InvalidLength(t *testing.T) {
p := NewPrecompile(AllOps()[3])

input := make([]byte, 100)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestPairing_IdentityPair(t *testing.T) {
p := NewPrecompile(AllOps()[4]) // PAIRING

// One pair: G1 identity (128 bytes) + G2 identity (256 bytes) = 384 bytes
input := make([]byte, 384)
result, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.NoError(t, err)
require.Equal(t, 32, len(result))
// Pairing check with identity points returns true (1)
expected := make([]byte, 32)
expected[31] = 1
require.Equal(t, expected, result)
}

func TestPairing_EmptyInput(t *testing.T) {
p := NewPrecompile(AllOps()[4])

// Empty input is invalid (k must be >= 1)
input := make([]byte, 0)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestPairing_InvalidLength(t *testing.T) {
p := NewPrecompile(AllOps()[4])

// Not a multiple of 384 bytes
input := make([]byte, 200)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestMapG1_InvalidLength(t *testing.T) {
p := NewPrecompile(AllOps()[5]) // MAP_FP_TO_G1

// Invalid input length (not 64 bytes)
input := make([]byte, 100)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestMapG2_InvalidLength(t *testing.T) {
p := NewPrecompile(AllOps()[6]) // MAP_FP2_TO_G2

// Invalid input length (not 128 bytes)
input := make([]byte, 100)
_, err := p.Run(nil, common.Address{}, common.Address{}, input, big.NewInt(0), false, false, nil)
require.Error(t, err)
}

func TestRequiredGas(t *testing.T) {
ops := AllOps()
tests := []struct {
name string
op OpInfo
inputSize int
expected uint64
}{
{"G1ADD", ops[0], 256, 375},
{"G1MSM_k1", ops[1], 160, 12000},
{"G2ADD", ops[2], 512, 600},
{"G2MSM_k1", ops[3], 288, 22500},
{"PAIRING_k1", ops[4], 384, 70300},
{"MAP_FP_TO_G1", ops[5], 64, 5500},
{"MAP_FP2_TO_G2", ops[6], 128, 23800},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewPrecompile(tt.op)
gas := p.RequiredGas(make([]byte, tt.inputSize))
require.Equal(t, tt.expected, gas)
})
}
}

func TestAddresses(t *testing.T) {
ops := AllOps()
expectedAddrs := []string{
"0x000000000000000000000000000000000000000b",
"0x000000000000000000000000000000000000000c",
"0x000000000000000000000000000000000000000d",
"0x000000000000000000000000000000000000000e",
"0x000000000000000000000000000000000000000f",
"0x0000000000000000000000000000000000000010",
"0x0000000000000000000000000000000000000011",
}

require.Equal(t, 7, len(ops))
for i, op := range ops {
p := NewPrecompile(op)
require.Equal(t, common.HexToAddress(expectedAddrs[i]), p.Address())
}
}
Loading