diff --git a/SIP-EIP-2537-BLS.md b/SIP-EIP-2537-BLS.md new file mode 100644 index 0000000000..e14b8b5bb6 --- /dev/null +++ b/SIP-EIP-2537-BLS.md @@ -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. diff --git a/contracts/BLSCheck.sol b/contracts/BLSCheck.sol new file mode 100644 index 0000000000..a9e764d032 --- /dev/null +++ b/contracts/BLSCheck.sol @@ -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; + } +} diff --git a/precompiles/bls/abi.json b/precompiles/bls/abi.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/precompiles/bls/abi.json @@ -0,0 +1 @@ +[] diff --git a/precompiles/bls/bls.go b/precompiles/bls/bls.go new file mode 100644 index 0000000000..c309977ba0 --- /dev/null +++ b/precompiles/bls/bls.go @@ -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 +} diff --git a/precompiles/bls/bls_test.go b/precompiles/bls/bls_test.go new file mode 100644 index 0000000000..99e6615254 --- /dev/null +++ b/precompiles/bls/bls_test.go @@ -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()) + } +} diff --git a/precompiles/setup.go b/precompiles/setup.go index 9224ec908a..716e93b7ee 100644 --- a/precompiles/setup.go +++ b/precompiles/setup.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/core/vm" "github.com/sei-protocol/sei-chain/precompiles/addr" "github.com/sei-protocol/sei-chain/precompiles/bank" + "github.com/sei-protocol/sei-chain/precompiles/bls" "github.com/sei-protocol/sei-chain/precompiles/distribution" "github.com/sei-protocol/sei-chain/precompiles/gov" "github.com/sei-protocol/sei-chain/precompiles/ibc" @@ -44,7 +45,7 @@ func GetCustomPrecompiles( latestUpgrade string, keepers utils.Keepers, ) map[ecommon.Address]utils.VersionedPrecompiles { - return map[ecommon.Address]utils.VersionedPrecompiles{ + m := map[ecommon.Address]utils.VersionedPrecompiles{ ecommon.HexToAddress(bank.BankAddress): bank.GetVersioned(latestUpgrade, keepers), ecommon.HexToAddress(wasmd.WasmdAddress): wasmd.GetVersioned(latestUpgrade, keepers), ecommon.HexToAddress(json.JSONAddress): json.GetVersioned(latestUpgrade, keepers), @@ -59,6 +60,12 @@ func GetCustomPrecompiles( ecommon.HexToAddress(p256.P256VerifyAddress): p256.GetVersioned(latestUpgrade, keepers), ecommon.HexToAddress(solo.SoloAddress): solo.GetVersioned(latestUpgrade, keepers), } + + for _, op := range bls.AllOps() { + m[ecommon.HexToAddress(op.Addr)] = bls.GetVersioned(op) + } + + return m } func InitializePrecompiles( @@ -146,8 +153,17 @@ func InitializePrecompiles( addPrecompileToVM(pointerp) addPrecompileToVM(pointerviewp) addPrecompileToVM(p256p) - Initialized = true } + + for _, op := range bls.AllOps() { + p := bls.NewPrecompile(op) + PrecompileNamesToInfo[p.GetName()] = PrecompileInfo{ABI: p.GetABI(), Address: p.Address()} + if !dryRun { + addPrecompileToVM(p) + } + } + + Initialized = true return nil }